Spaces:
Running
Running
Upload 10 files
Browse files- src/services/classroom.js +22 -0
- src/utils/monsterUtils.js +47 -16
- src/views/StudentView.js +58 -9
- test_evolution.js +29 -0
src/services/classroom.js
CHANGED
|
@@ -446,6 +446,11 @@ export async function markNotificationRead(notificationId) {
|
|
| 446 |
});
|
| 447 |
}
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
/**
|
| 450 |
* Gets the number of users in a room
|
| 451 |
* @param {string} roomCode
|
|
@@ -460,6 +465,23 @@ export async function getClassSize(roomCode) {
|
|
| 460 |
return snapshot.data().count;
|
| 461 |
}
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
/**
|
| 464 |
* Updates a student's monster stage and specific form
|
| 465 |
* @param {string} userId
|
|
|
|
| 446 |
});
|
| 447 |
}
|
| 448 |
|
| 449 |
+
/**
|
| 450 |
+
* Gets the number of users in a room
|
| 451 |
+
* @param {string} roomCode
|
| 452 |
+
* @returns {Promise<number>}
|
| 453 |
+
*/
|
| 454 |
/**
|
| 455 |
* Gets the number of users in a room
|
| 456 |
* @param {string} roomCode
|
|
|
|
| 465 |
return snapshot.data().count;
|
| 466 |
}
|
| 467 |
|
| 468 |
+
/**
|
| 469 |
+
* Gets the number of students who have reached a higher or equal stage
|
| 470 |
+
* Used for determining percentile ranking
|
| 471 |
+
* @param {string} roomCode
|
| 472 |
+
* @param {number} targetStage
|
| 473 |
+
* @returns {Promise<number>}
|
| 474 |
+
*/
|
| 475 |
+
export async function getHigherStageCount(roomCode, targetStage) {
|
| 476 |
+
const q = query(
|
| 477 |
+
collection(db, USERS_COLLECTION),
|
| 478 |
+
where("current_room", "==", roomCode),
|
| 479 |
+
where("monster_stage", ">=", targetStage)
|
| 480 |
+
);
|
| 481 |
+
const snapshot = await getCountFromServer(q);
|
| 482 |
+
return snapshot.data().count;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
/**
|
| 486 |
* Updates a student's monster stage and specific form
|
| 487 |
* @param {string} userId
|
src/utils/monsterUtils.js
CHANGED
|
@@ -404,7 +404,16 @@ export function generateMonsterSVG(monster) {
|
|
| 404 |
* @param {string} [currentMonsterId] - ID of the current monster to determine lineage
|
| 405 |
* @returns {Object} Selected Monster Definition
|
| 406 |
*/
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
if (currentStage === 0) return MONSTER_DEFS.find(m => m.id === 'Egg');
|
| 409 |
|
| 410 |
// Find current monster family if we have an ID
|
|
@@ -415,28 +424,50 @@ export function getNextMonster(currentStage, likes, classSize, currentMonsterId
|
|
| 415 |
}
|
| 416 |
|
| 417 |
// Stage 1: Basic Families (Origin -> Family)
|
| 418 |
-
// If coming from Egg (Stage 0), we pick a base family.
|
| 419 |
if (currentStage === 1) {
|
| 420 |
-
// Simple distribution
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
}
|
| 425 |
|
| 426 |
// Branching Logic for Stage 2 & 3
|
| 427 |
-
//
|
| 428 |
-
|
| 429 |
-
// C: Low Likes ( <= 1 )
|
| 430 |
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
// Tier Logic
|
| 434 |
let tier = 'C';
|
| 435 |
-
if (
|
| 436 |
-
else if (
|
| 437 |
|
| 438 |
-
// Safety check for small classes
|
| 439 |
-
if (classSize <= 2
|
|
|
|
|
|
|
|
|
|
| 440 |
|
| 441 |
// Filter potential monsters for this Stage
|
| 442 |
let candidates = MONSTER_DEFS.filter(m => m.stage === currentStage);
|
|
@@ -449,8 +480,8 @@ export function getNextMonster(currentStage, likes, classSize, currentMonsterId
|
|
| 449 |
if (isStandardId) {
|
| 450 |
// Extract path suffix (e.g. "A" from "L1_A", "AB" from "L2_AB")
|
| 451 |
const currentPath = currentMonsterId.split('_')[1];
|
| 452 |
-
// Filter candidates: Must start with L{
|
| 453 |
-
//
|
| 454 |
const strictMatches = candidates.filter(m => m.id.startsWith(`L${currentStage}_${currentPath}`));
|
| 455 |
|
| 456 |
if (strictMatches.length > 0) {
|
|
|
|
| 404 |
* @param {string} [currentMonsterId] - ID of the current monster to determine lineage
|
| 405 |
* @returns {Object} Selected Monster Definition
|
| 406 |
*/
|
| 407 |
+
/**
|
| 408 |
+
* Determines the next monster form based on parameters and lineage
|
| 409 |
+
* @param {number} currentStage - Current evolutionary stage (0-3)
|
| 410 |
+
* @param {number} stageLikes - Likes earned IN THIS STAGE
|
| 411 |
+
* @param {number} classSize - Active class size
|
| 412 |
+
* @param {string} [currentMonsterId] - ID of the current monster to determine lineage
|
| 413 |
+
* @param {number} [percentile] - Speed ranking percentile (0.0 - 1.0, lower is faster). Default 1.0
|
| 414 |
+
* @returns {Object} Selected Monster Definition
|
| 415 |
+
*/
|
| 416 |
+
export function getNextMonster(currentStage, stageLikes, classSize, currentMonsterId = null, percentile = 1.0) {
|
| 417 |
if (currentStage === 0) return MONSTER_DEFS.find(m => m.id === 'Egg');
|
| 418 |
|
| 419 |
// Find current monster family if we have an ID
|
|
|
|
| 424 |
}
|
| 425 |
|
| 426 |
// Stage 1: Basic Families (Origin -> Family)
|
| 427 |
+
// If coming from Egg (Stage 0), we pick a base family based on performance.
|
| 428 |
if (currentStage === 1) {
|
| 429 |
+
// Simple distribution for Stage 1 (Egg -> Base)
|
| 430 |
+
// We can use the same Score Logic or keep simple likes.
|
| 431 |
+
// Let's use the Score Logic to be consistent with design.
|
| 432 |
+
|
| 433 |
+
let score = 0;
|
| 434 |
+
// Speed Score
|
| 435 |
+
if (percentile <= 0.3) score += 5; // Top 30%
|
| 436 |
+
else if (percentile <= 0.6) score += 3; // Top 60%
|
| 437 |
+
else score += 1; // Rest
|
| 438 |
+
|
| 439 |
+
// Like Score
|
| 440 |
+
if (stageLikes >= 2) score += 2;
|
| 441 |
+
else if (stageLikes > 0) score += 1;
|
| 442 |
+
|
| 443 |
+
if (score >= 5) return MONSTER_DEFS.find(m => m.id === 'L1_A'); // High (Spirit)
|
| 444 |
+
if (score >= 3) return MONSTER_DEFS.find(m => m.id === 'L1_B'); // Mid (Beast)
|
| 445 |
+
return MONSTER_DEFS.find(m => m.id === 'L1_C'); // Low (Dust)
|
| 446 |
}
|
| 447 |
|
| 448 |
// Branching Logic for Stage 2 & 3
|
| 449 |
+
// Score Calculation
|
| 450 |
+
let score = 0;
|
|
|
|
| 451 |
|
| 452 |
+
// 1. Speed Score
|
| 453 |
+
if (percentile <= 0.3) score += 5; // Top 30%
|
| 454 |
+
else if (percentile <= 0.6) score += 3; // Top 60%
|
| 455 |
+
else score += 1; // Rest
|
| 456 |
+
|
| 457 |
+
// 2. Like Score
|
| 458 |
+
if (stageLikes >= 2) score += 2; // High Likes
|
| 459 |
+
else if (stageLikes > 0) score += 1; // Any Likes
|
| 460 |
|
| 461 |
// Tier Logic
|
| 462 |
let tier = 'C';
|
| 463 |
+
if (score >= 5) tier = 'A'; // High Tier
|
| 464 |
+
else if (score >= 3) tier = 'B'; // Mid Tier
|
| 465 |
|
| 466 |
+
// Safety check for small classes (Boost if alone)
|
| 467 |
+
if (classSize <= 2) {
|
| 468 |
+
if (stageLikes >= 1) tier = 'A';
|
| 469 |
+
else tier = 'B';
|
| 470 |
+
}
|
| 471 |
|
| 472 |
// Filter potential monsters for this Stage
|
| 473 |
let candidates = MONSTER_DEFS.filter(m => m.stage === currentStage);
|
|
|
|
| 480 |
if (isStandardId) {
|
| 481 |
// Extract path suffix (e.g. "A" from "L1_A", "AB" from "L2_AB")
|
| 482 |
const currentPath = currentMonsterId.split('_')[1];
|
| 483 |
+
// Filter candidates: Must start with L{targetStage}_{currentPath}
|
| 484 |
+
// currentStage IS the target stage (e.g. 3), so we look for L3_...
|
| 485 |
const strictMatches = candidates.filter(m => m.id.startsWith(`L${currentStage}_${currentPath}`));
|
| 486 |
|
| 487 |
if (strictMatches.length > 0) {
|
src/views/StudentView.js
CHANGED
|
@@ -696,18 +696,67 @@ window.triggerEvolution = async (currentStage, nextStage, likes, classSize, curr
|
|
| 696 |
|
| 697 |
try {
|
| 698 |
const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js");
|
| 699 |
-
const { updateUserMonster } = await import("../services/classroom.js");
|
| 700 |
|
|
|
|
|
|
|
|
|
|
| 701 |
|
| 702 |
-
//
|
| 703 |
-
//
|
| 704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
|
| 706 |
-
//
|
| 707 |
-
// We
|
| 708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
|
| 710 |
-
console.log("Evolving from:",
|
| 711 |
|
| 712 |
const container = document.querySelector('#monster-container-fixed .pixel-monster');
|
| 713 |
const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container');
|
|
@@ -720,7 +769,7 @@ window.triggerEvolution = async (currentStage, nextStage, likes, classSize, curr
|
|
| 720 |
const maxFlickers = 12; // Increased duration
|
| 721 |
let speed = 300;
|
| 722 |
|
| 723 |
-
const
|
| 724 |
const svgNext = generateMonsterSVG(nextMonster);
|
| 725 |
|
| 726 |
const setFrame = (svg, isSilhouette) => {
|
|
|
|
| 696 |
|
| 697 |
try {
|
| 698 |
const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js");
|
| 699 |
+
const { updateUserMonster, getHigherStageCount } = await import("../services/classroom.js");
|
| 700 |
|
| 701 |
+
// Calculate Ranking Percentile
|
| 702 |
+
const roomCode = localStorage.getItem('vibecoding_room_code');
|
| 703 |
+
let percentile = 1.0;
|
| 704 |
|
| 705 |
+
// Query how many already at next stage (or same level but evolved)
|
| 706 |
+
// Actually, we want to know how many have *completed* this stage essentially.
|
| 707 |
+
// Since we trigger evolution AFTER completion, anyone AT or ABOVE nextStage has completed it.
|
| 708 |
+
// So we count how many are >= nextStage.
|
| 709 |
+
// Wait, if I am the first to evolve to stage 1, count >= 1 is 0.
|
| 710 |
+
// So rank is (0 + 1) / classSize.
|
| 711 |
+
|
| 712 |
+
try {
|
| 713 |
+
const higherCount = await getHigherStageCount(roomCode, nextStage);
|
| 714 |
+
percentile = (higherCount + 1) / classSize;
|
| 715 |
+
console.log(`Evolution Rank: ${higherCount + 1}/${classSize} (${percentile})`);
|
| 716 |
+
} catch (e) {
|
| 717 |
+
console.error("Rank calc failed", e);
|
| 718 |
+
}
|
| 719 |
|
| 720 |
+
// Calculate Stage-Specific Likes
|
| 721 |
+
// We need to fetch challenges to filter by level...
|
| 722 |
+
// cachedChallenges is available in module scope
|
| 723 |
+
// We need user progress...
|
| 724 |
+
// We can't easily get it here without re-fetching or passing it in.
|
| 725 |
+
// But we have 'likes' passed in, which is TOTAL likes.
|
| 726 |
+
// Re-fetching progress is safer.
|
| 727 |
+
const userId = localStorage.getItem('vibecoding_user_id');
|
| 728 |
+
const { getUserProgress } = await import("../services/classroom.js");
|
| 729 |
+
const progress = await getUserProgress(userId);
|
| 730 |
+
|
| 731 |
+
let stageLikes = 0;
|
| 732 |
+
// Map stage to challenge level
|
| 733 |
+
// Stage 0 -> 1 requires Beginner (Level 1) likes
|
| 734 |
+
// Stage 1 -> 2 requires Intermediate (Level 2) likes
|
| 735 |
+
// Stage 2 -> 3 requires Advanced (Level 3) likes
|
| 736 |
+
|
| 737 |
+
// Note: nextStage parameter is the TARGET stage.
|
| 738 |
+
// If nextStage is 1 (Egg->Baby), we count Beginner likes.
|
| 739 |
+
|
| 740 |
+
const targetLevelMap = {
|
| 741 |
+
1: 'beginner',
|
| 742 |
+
2: 'intermediate',
|
| 743 |
+
3: 'advanced'
|
| 744 |
+
};
|
| 745 |
+
const targetLevel = targetLevelMap[nextStage];
|
| 746 |
+
|
| 747 |
+
if (targetLevel) {
|
| 748 |
+
stageLikes = cachedChallenges
|
| 749 |
+
.filter(c => c.level === targetLevel)
|
| 750 |
+
.reduce((acc, c) => acc + (progress[c.id]?.likes || 0), 0);
|
| 751 |
+
}
|
| 752 |
+
console.log(`Stage ${nextStage} Likes: ${stageLikes} (Total: ${likes})`);
|
| 753 |
+
|
| 754 |
+
// Calculate Next Monster with Lineage & Ranking
|
| 755 |
+
// IMPORTANT: Ensure we pass currentMonsterId to enforce lineage!
|
| 756 |
+
// We pass 'stageLikes' instead of total 'likes' now
|
| 757 |
+
const nextMonster = getNextMonster(currentStage, stageLikes, classSize, currentMonsterId, percentile);
|
| 758 |
|
| 759 |
+
console.log("Evolving from:", currentMonsterId, "to:", nextMonster.name);
|
| 760 |
|
| 761 |
const container = document.querySelector('#monster-container-fixed .pixel-monster');
|
| 762 |
const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container');
|
|
|
|
| 769 |
const maxFlickers = 12; // Increased duration
|
| 770 |
let speed = 300;
|
| 771 |
|
| 772 |
+
const currentMonster = MONSTER_DEFS.find(m => m.id === currentMonsterId) || getNextMonster(currentStage, 0, 0, currentMonsterId);
|
| 773 |
const svgNext = generateMonsterSVG(nextMonster);
|
| 774 |
|
| 775 |
const setFrame = (svg, isSilhouette) => {
|
test_evolution.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getNextMonster, MONSTER_DEFS } from './src/utils/monsterUtils.js';
|
| 2 |
+
|
| 3 |
+
const mockMonster = { id: 'L2_CA', name: '駭客蟲', stage: 2 };
|
| 4 |
+
const classSizes = [10, 30];
|
| 5 |
+
const likeCounts = [0, 5, 20]; // Test Low, Mid, High tiers
|
| 6 |
+
|
| 7 |
+
console.log(`Testing Evolution for: ${mockMonster.name} (${mockMonster.id})`);
|
| 8 |
+
|
| 9 |
+
classSizes.forEach(size => {
|
| 10 |
+
likeCounts.forEach(likes => {
|
| 11 |
+
// We want to see what it evolves INTO, so we ask for Stage 3
|
| 12 |
+
const next = getNextMonster(3, likes, size, mockMonster.id);
|
| 13 |
+
console.log(`Class: ${size}, Likes: ${likes} -> Next: ${next.name} (${next.id})`);
|
| 14 |
+
});
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
console.log('\n--- Checking Evolution for Trash Mob (L2_CC) ---');
|
| 18 |
+
// Verify if L2_CC evolves to Virus King (L3_CCA) or others
|
| 19 |
+
const trashMob = { id: 'L2_CC', name: '垃圾怪', stage: 2 };
|
| 20 |
+
const nextTrash = getNextMonster(3, 5, 10, trashMob.id);
|
| 21 |
+
console.log(`Trash Mob (L2_CC) w/ High Likes -> ${nextTrash.name} (${nextTrash.id})`);
|
| 22 |
+
|
| 23 |
+
console.log('\n--- Debugging candidates for L2_CA ---');
|
| 24 |
+
// Manually replicate the util logic step
|
| 25 |
+
const candidates = MONSTER_DEFS.filter(m => m.stage === 3);
|
| 26 |
+
const currentPath = 'CA';
|
| 27 |
+
const rigidMatches = candidates.filter(m => m.id.startsWith(`L3_${currentPath}`));
|
| 28 |
+
console.log('Strict Matches for L3_CA*:');
|
| 29 |
+
rigidMatches.forEach(m => console.log(m.id, m.name));
|