From 06d1dee00ac01816472713f90e6800b3eca0c3b6 Mon Sep 17 00:00:00 2001 From: Season Date: Mon, 20 Jul 2026 10:49:35 +0800 Subject: [PATCH 1/2] Finish the Meadow guardian slate, badge, and starter evolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the fixed Cloudmane finale: a hand-checked multi-topic question bank served in authored order, the Meadow Badge awarded once on victory, and the starter located by starterCreatureId always evolving to stage 2 — active or stored, no FOMO. --- .../std1.meadow-guardian.v1.gate.json | 170 ++++++++++++++ .../std1.meadow-guardian.v1.gate.md | 32 +++ .../std1/std1.meadow-guardian.v1.json | 215 ++++++++++++++++++ .../std1/std1.meadow-guardian.v1.json.meta | 11 + game/assets/shared/creature.ts | 14 ++ game/assets/shared/index.ts | 1 + game/assets/shared/meadow-finale.ts | 72 ++++++ game/assets/src/GameApp.ts | 54 ++++- game/assets/src/battle/BattleScreen.ts | 21 +- game/assets/src/creature-portrait.ts | 7 +- game/assets/src/party/PartyScreen.ts | 6 +- game/assets/src/sanctuary/SanctuaryScreen.ts | 6 +- game/assets/src/state.ts | 27 +++ game/assets/src/world/WorldScreen.ts | 12 +- game/assets/src/world/trail.ts | 23 +- game/tests/trail.test.ts | 16 +- shared/creature.ts | 14 ++ shared/index.ts | 1 + shared/meadow-finale.ts | 72 ++++++ shared/tests/meadow-finale.test.ts | 107 +++++++++ shared/tests/meadow-guardian-bank.test.ts | 79 +++++++ 21 files changed, 937 insertions(+), 23 deletions(-) create mode 100644 docs/question-banks/std1.meadow-guardian.v1.gate.json create mode 100644 docs/question-banks/std1.meadow-guardian.v1.gate.md create mode 100644 game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json create mode 100644 game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json.meta create mode 100644 game/assets/shared/meadow-finale.ts create mode 100644 shared/meadow-finale.ts create mode 100644 shared/tests/meadow-finale.test.ts create mode 100644 shared/tests/meadow-guardian-bank.test.ts diff --git a/docs/question-banks/std1.meadow-guardian.v1.gate.json b/docs/question-banks/std1.meadow-guardian.v1.gate.json new file mode 100644 index 0000000..36be917 --- /dev/null +++ b/docs/question-banks/std1.meadow-guardian.v1.gate.json @@ -0,0 +1,170 @@ +{ + "tool": "tools/validate-question-bank.mjs", + "generatedAt": "2026-07-20T02:49:05.368Z", + "source": "std1.meadow-guardian.v1.json", + "sha256": "9f331c9fadb452cfaab4e30e1f08f397477f83ce5dc20d4053bdf298d5a03475", + "accept": true, + "report": { + "bankId": "std1.meadow-guardian", + "bankVersion": 1, + "schemaVersion": 2, + "questionCount": 5, + "accept": true, + "phases": [ + { + "phase": "structural", + "accept": true, + "findings": [] + }, + { + "phase": "mechanical", + "accept": true, + "findings": [] + }, + { + "phase": "adversarial", + "accept": true, + "findings": [] + } + ] + }, + "jsonSchema": [], + "checklist": [ + { + "rule": "SCHEMA-V2", + "phase": "structural", + "source": "docs/question-banks/schema-v2.md", + "description": "Bank parses at the trust boundary: a supported schema_version, every required field, no unknown fields, answer-form and figure payloads well-formed.", + "result": "pass", + "findings": [] + }, + { + "rule": "SCOPE-100", + "phase": "mechanical", + "source": "scope doc §2", + "description": "Every number in the problem (operands, answer, distractors, table values) is within [0, 100].", + "result": "pass", + "findings": [] + }, + { + "rule": "OPS-PLUSMINUS", + "phase": "mechanical", + "source": "scope doc §2", + "description": "Only + and − appear; × ÷ * / and parentheses are rejected.", + "result": "pass", + "findings": [] + }, + { + "rule": "STEP-SINGLE", + "phase": "mechanical", + "source": "scope doc §2", + "description": "Single-step items; chained subtraction and mixed +/− chains are rejected (pure repeated addition is allowed).", + "result": "pass", + "findings": [] + }, + { + "rule": "ANSWER-REDERIVE", + "phase": "mechanical", + "source": "issue #14", + "description": "The answer is independently re-derived from expression and must match — including the declared order on ordering items; wrong answers are rejected, never edited.", + "result": "pass", + "findings": [] + }, + { + "rule": "OPERATION-LABEL", + "phase": "mechanical", + "source": "schema-v2 doc", + "description": "operation labels the arithmetic in expression (counting / addition / subtraction); ordering and numeral comparisons label counting.", + "result": "pass", + "findings": [] + }, + { + "rule": "MONEY-RM10", + "phase": "mechanical", + "source": "scope doc §2/§4.3", + "description": "Ringgit amounts stay ≤ RM10 (answer_unit RM caps every number at 10).", + "result": "pass", + "findings": [] + }, + { + "rule": "MONEY-SEN100", + "phase": "mechanical", + "source": "scope doc §2/§4.3", + "description": "Sen amounts stay ≤ RM1 (answer_unit sen caps every number at 100).", + "result": "pass", + "findings": [] + }, + { + "rule": "MONEY-MIXED", + "phase": "mechanical", + "source": "scope doc §4.3", + "description": "No mixed coin-note exchange: a prompt exchanging equivalent value across the ringgit/sen boundary is rejected.", + "result": "pass", + "findings": [] + }, + { + "rule": "DISTRACTORS", + "phase": "mechanical", + "source": "schema-v2 doc; style doc §D", + "description": "Authored distractors are in-scope, unique, distinct from the answer, and strategy-annotated.", + "result": "pass", + "findings": [] + }, + { + "rule": "BILINGUAL", + "phase": "mechanical", + "source": "style doc §B; scope doc §3", + "description": "bilingual.numeral is the answer's digits; zh_word matches the derived Chinese number word.", + "result": "pass", + "findings": [] + }, + { + "rule": "RAW-NUMERAL", + "phase": "adversarial", + "source": "issue #14", + "description": "Raw bilingual.numeral equals String(answer) — checked on the raw JSON, not the parsed object.", + "result": "pass", + "findings": [] + }, + { + "rule": "RAW-IDS", + "phase": "adversarial", + "source": "schema-v2 doc", + "description": "Question ids are unique positive integers in the raw JSON.", + "result": "pass", + "findings": [] + }, + { + "rule": "UNIT-TOPIC", + "phase": "adversarial", + "source": "scope doc §4.3", + "description": "answer_unit RM/sen appears only on topic 4.3 (money); money items outside 4.3 are incoherent.", + "result": "pass", + "findings": [] + }, + { + "rule": "STRATEGY-TOPIC", + "phase": "adversarial", + "source": "style doc §D", + "description": "money-denom-miscount distractors only on money items; clock-hand-swap only on topic 4.4 (time).", + "result": "pass", + "findings": [] + }, + { + "rule": "EXTRA-PROFILE", + "phase": "adversarial", + "source": "scope doc §1/§5", + "description": "topic \"extra\" requires profile original_dskp_extra; core topics gated to the extra profile warn (they are hidden from core-profile children).", + "result": "pass", + "findings": [] + }, + { + "rule": "ZH-WORD-DRIFT", + "phase": "adversarial", + "source": "style doc §B", + "description": "Raw bilingual.zh_word matches the independently derived Chinese number word (human-review evidence on drift).", + "result": "pass", + "findings": [] + } + ] +} diff --git a/docs/question-banks/std1.meadow-guardian.v1.gate.md b/docs/question-banks/std1.meadow-guardian.v1.gate.md new file mode 100644 index 0000000..be3b03b --- /dev/null +++ b/docs/question-banks/std1.meadow-guardian.v1.gate.md @@ -0,0 +1,32 @@ +# Validation gate report — std1.meadow-guardian + +> Generated by tools/validate-question-bank.mjs. Do not edit; re-run the gate. + +**Verdict:** ✅ ACCEPT +**Bank:** `std1.meadow-guardian` v1 · schema v2 · 5 questions +**Source:** [std1.meadow-guardian.v1.json](../../game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json) +**Content SHA-256:** `9f331c9fadb452cfaab4e30e1f08f397477f83ce5dc20d4053bdf298d5a03475` + +## Corpus checklist + +| Rule | Phase | Result | Evidence | +|---|---|---|---| +| SCHEMA-V2 | structural | ✅ | Bank parses at the trust boundary: a supported schema_version, every required field, no unknown fields, answer-form and figure payloads well-formed. | +| SCOPE-100 | mechanical | ✅ | Every number in the problem (operands, answer, distractors, table values) is within [0, 100]. | +| OPS-PLUSMINUS | mechanical | ✅ | Only + and − appear; × ÷ * / and parentheses are rejected. | +| STEP-SINGLE | mechanical | ✅ | Single-step items; chained subtraction and mixed +/− chains are rejected (pure repeated addition is allowed). | +| ANSWER-REDERIVE | mechanical | ✅ | The answer is independently re-derived from expression and must match — including the declared order on ordering items; wrong answers are rejected, never edited. | +| OPERATION-LABEL | mechanical | ✅ | operation labels the arithmetic in expression (counting / addition / subtraction); ordering and numeral comparisons label counting. | +| MONEY-RM10 | mechanical | ✅ | Ringgit amounts stay ≤ RM10 (answer_unit RM caps every number at 10). | +| MONEY-SEN100 | mechanical | ✅ | Sen amounts stay ≤ RM1 (answer_unit sen caps every number at 100). | +| MONEY-MIXED | mechanical | ✅ | No mixed coin-note exchange: a prompt exchanging equivalent value across the ringgit/sen boundary is rejected. | +| DISTRACTORS | mechanical | ✅ | Authored distractors are in-scope, unique, distinct from the answer, and strategy-annotated. | +| BILINGUAL | mechanical | ✅ | bilingual.numeral is the answer's digits; zh_word matches the derived Chinese number word. | +| RAW-NUMERAL | adversarial | ✅ | Raw bilingual.numeral equals String(answer) — checked on the raw JSON, not the parsed object. | +| RAW-IDS | adversarial | ✅ | Question ids are unique positive integers in the raw JSON. | +| UNIT-TOPIC | adversarial | ✅ | answer_unit RM/sen appears only on topic 4.3 (money); money items outside 4.3 are incoherent. | +| STRATEGY-TOPIC | adversarial | ✅ | money-denom-miscount distractors only on money items; clock-hand-swap only on topic 4.4 (time). | +| EXTRA-PROFILE | adversarial | ✅ | topic "extra" requires profile original_dskp_extra; core topics gated to the extra profile warn (they are hidden from core-profile children). | +| ZH-WORD-DRIFT | adversarial | ✅ | Raw bilingual.zh_word matches the independently derived Chinese number word (human-review evidence on drift). | + +Every checklist row passed. The bank may enter an approved bank. diff --git a/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json b/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json new file mode 100644 index 0000000..0fe0590 --- /dev/null +++ b/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json @@ -0,0 +1,215 @@ +{ + "schema_version": 2, + "bank_id": "std1.meadow-guardian", + "version": 1, + "source": "Hand-checked fixed guardian slate (issue #23): one item per locked topic — counting, +/−, clock, money, pictograph — mostly TP3–4 with one TP5 twist. Deterministic order; served sequentially in the Cloudmane battle.", + "currency": "RM", + "profile": "dpk3_2026_core", + "scope": "meadow-isle.md §8 Hundred Stones guardian: fixed authored set (count, + − sentence, clock read, money change, pictograph read)", + "questions": [ + { + "id": 1, + "topic": "4.1", + "tp_level": 4, + "profile": "dpk3_2026_core", + "item_format": "objective", + "format_type": "fill-blank", + "presentation": "plain", + "answer_form": "numeral", + "answer_unit": "none", + "operation": "counting", + "expression": "47", + "answer": 47, + "bilingual": { + "numeral": "47", + "zh_word": "四十七" + }, + "question_zh": "百石原亮起了 4 个十和 7 个一。合起来是几?", + "question_en": "Among the Hundred Stones, 4 tens and 7 ones lit up. What number is that altogether?", + "distractors": [ + { + "value": 74, + "strategy": "digit-reversal" + }, + { + "value": 40, + "strategy": "place-value-slip" + }, + { + "value": 11, + "strategy": "count-all-vs-add" + } + ] + }, + { + "id": 2, + "topic": "4.2", + "tp_level": 3, + "profile": "dpk3_2026_core", + "item_format": "objective", + "format_type": "picture-sentence", + "presentation": "figure:objects", + "answer_form": "numeral", + "answer_unit": "none", + "operation": "subtraction", + "expression": "18 - 4", + "answer": 14, + "bilingual": { + "numeral": "14", + "zh_word": "十四" + }, + "question_zh": "看图写出算式:一共有 18 块亮着的石头,暗下 4 块,还剩几块? 18 - 4 = ___", + "question_en": "Write the number sentence: 18 stones lit, 4 went dark. How many still shine? 18 - 4 = ___", + "distractors": [ + { + "value": 22, + "strategy": "wrong-operation" + }, + { + "value": 4, + "strategy": "raw-operand" + }, + { + "value": 15, + "strategy": "off-by-one-count" + } + ], + "figure": { + "kind": "objects", + "icon": "🪨", + "count": 18, + "crossedOut": 4 + } + }, + { + "id": 3, + "topic": "4.4", + "tp_level": 3, + "profile": "dpk3_2026_core", + "item_format": "objective", + "format_type": "read-instrument", + "presentation": "figure:clock", + "answer_form": "numeral", + "answer_unit": "none", + "operation": "counting", + "expression": "6", + "answer": 6, + "bilingual": { + "numeral": "6", + "zh_word": "六" + }, + "question_zh": "钟面上是几时一刻?", + "question_en": "It is quarter past which hour?", + "distractors": [ + { + "value": 3, + "strategy": "clock-hand-swap" + }, + { + "value": 7, + "strategy": "next-vs-between" + }, + { + "value": 5, + "strategy": "off-by-one-count" + } + ], + "figure": { + "kind": "clock", + "hour": 6, + "minute": 15 + } + }, + { + "id": 4, + "topic": "4.3", + "tp_level": 3, + "profile": "dpk3_2026_core", + "item_format": "objective", + "format_type": "word-single", + "presentation": "story", + "answer_form": "numeral", + "answer_unit": "RM", + "operation": "subtraction", + "expression": "8 - 3", + "answer": 5, + "bilingual": { + "numeral": "5", + "zh_word": "五" + }, + "question_zh": "守石人有 RM8,买了一盏小灯用了 RM3,现在还有多少钱?", + "question_en": "Keeper Yun had RM8 and spent RM3 on a small lantern. How much money is left?", + "distractors": [ + { + "value": 3, + "strategy": "raw-operand" + }, + { + "value": 8, + "strategy": "raw-operand" + }, + { + "value": 4, + "strategy": "off-by-one-count" + } + ] + }, + { + "id": 5, + "topic": "4.7", + "tp_level": 5, + "profile": "dpk3_2026_core", + "item_format": "objective", + "format_type": "read-instrument", + "presentation": "figure:pictograph", + "answer_form": "numeral", + "answer_unit": "none", + "operation": "addition", + "expression": "6 + 4 + 8", + "answer": 18, + "bilingual": { + "numeral": "18", + "zh_word": "十八" + }, + "question_zh": "丰收板上,三种作物一共有几个?也可以先算 6 + 4 = 10,再算 10 + 8。", + "question_en": "On the harvest board, how many of all three crops are there in total? You can also do 6 + 4 = 10, then 10 + 8.", + "distractors": [ + { + "value": 10, + "strategy": "count-all-vs-add" + }, + { + "value": 14, + "strategy": "off-by-one-count" + }, + { + "value": 20, + "strategy": "off-by-one-count" + } + ], + "figure": { + "kind": "pictograph", + "rows": [ + { + "icon": "🍎", + "label_zh": "苹果", + "label_en": "apples", + "count": 6 + }, + { + "icon": "🎃", + "label_zh": "南瓜", + "label_en": "pumpkins", + "count": 4 + }, + { + "icon": "🥕", + "label_zh": "胡萝卜", + "label_en": "carrots", + "count": 8 + } + ] + } + } + ] +} diff --git a/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json.meta b/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json.meta new file mode 100644 index 0000000..1d22a01 --- /dev/null +++ b/game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "bf36f67d-f301-4ab8-a908-9788f02f6fda", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/game/assets/shared/creature.ts b/game/assets/shared/creature.ts index 0e45614..b502cb4 100644 --- a/game/assets/shared/creature.ts +++ b/game/assets/shared/creature.ts @@ -120,6 +120,20 @@ export function isStarterId(value: unknown): value is string { return typeof value === "string" && STARTERS.some((s) => s.id === value); } +/** + * The art cell for a 1-based evolution stage. Stage-1 art is the species' + * registered `artRef` (which may already offset past an empty leading pack + * cell); later stages step right by one cell width. Clamps to [1, stages]. + * Missing art stays undefined so portraits fall back to the blob. + */ +export function artRefForStage(species: Species, stage: number): SpeciesArt | undefined { + const art = species.artRef; + if (!art) return undefined; + const clamped = Math.max(1, Math.min(Math.floor(stage) || 1, species.stages)); + if (clamped === 1) return art; + return { ...art, x: art.x + (clamped - 1) * art.w }; +} + // Legacy prototype species (pre-registry). Kept registered so old save // records that reference them still resolve; they have no Chinese name and // no habitat — the Meadow slate below is the roster that matters. diff --git a/game/assets/shared/index.ts b/game/assets/shared/index.ts index 30958fd..6a5d026 100644 --- a/game/assets/shared/index.ts +++ b/game/assets/shared/index.ts @@ -24,4 +24,5 @@ export * from "./save-v2"; export * from "./save-v2-validate"; export * from "./save-migrate"; export * from "./habitats"; +export * from "./meadow-finale"; export * from "./telemetry"; diff --git a/game/assets/shared/meadow-finale.ts b/game/assets/shared/meadow-finale.ts new file mode 100644 index 0000000..9346771 --- /dev/null +++ b/game/assets/shared/meadow-finale.ts @@ -0,0 +1,72 @@ +// Meadow Isle finale (M6, #23): the Meadow Badge and guaranteed starter +// evolution after the Cloud-Maned Horse guardian battle. +// +// Design authority: docs/islands/meadow-isle.md §8 + locked decisions — +// - Victory awards the Meadow Badge exactly once and persists it. +// - The starter identified by `starterCreatureId` evolves whether active +// or stored, with no FOMO condition. +// +// Pure domain: GameState applies the result; World/GameApp only show notices. + +import { SPECIES_BY_ID } from "./creature"; +import type { OwnedCreatureState, SaveStateV2 } from "./save-v2"; + +/** The island badge id on save v2 `badges`. Awarded once, never revoked. */ +export const MEADOW_BADGE = "meadow-badge"; + +/** Stage the Meadow Badge evolves the starter to (1-based). */ +export const MEADOW_STARTER_EVOLUTION_STAGE = 2; + +/** Shown the first time the Meadow Badge is earned. */ +export const MEADOW_BADGE_NOTICE = + "You earned the Meadow Badge! The island remembers your kindness. " + + "获得了牧场徽章!整座岛都记得你的温柔。"; + +/** Shown when the starter's stage advances with the badge. */ +export function starterEvolvedNotice(name: string): string { + return ( + `${name} is glowing — it evolved! ${name} 身上亮起了光——它进化了!` + ); +} + +export interface MeadowFinaleResult { + readonly save: SaveStateV2; + /** True only the first time the badge is written. */ + readonly badgeAwarded: boolean; + /** The starter's display name when its stage just advanced; null otherwise. */ + readonly evolvedName: string | null; +} + +/** + * Apply the Hundred Stones victory payoffs to a save: + * 1. Award `meadow-badge` if not already held. + * 2. Evolve the starter located by `starterCreatureId` to stage 2 when the + * species has a second stage and the starter is still stage 1. + * + * Idempotent: a second victory is a pure no-op (badge stays once; stage stays + * at 2). Never mutates its input. + */ +export function awardMeadowFinale(save: SaveStateV2): MeadowFinaleResult { + const badgeAwarded = !save.badges.includes(MEADOW_BADGE); + const badges = badgeAwarded ? [...save.badges, MEADOW_BADGE] : save.badges; + + let evolvedName: string | null = null; + const ownedCreatures: OwnedCreatureState[] = save.ownedCreatures.map((creature) => { + if (creature.creatureId !== save.starterCreatureId) return creature; + if (creature.stage >= MEADOW_STARTER_EVOLUTION_STAGE) return creature; + const species = SPECIES_BY_ID[creature.speciesId]; + if (!species || species.stages < MEADOW_STARTER_EVOLUTION_STAGE) return creature; + evolvedName = creature.name; + return { ...creature, stage: MEADOW_STARTER_EVOLUTION_STAGE }; + }); + + if (!badgeAwarded && evolvedName === null) { + return { save, badgeAwarded: false, evolvedName: null }; + } + + return { + save: { ...save, badges, ownedCreatures }, + badgeAwarded, + evolvedName, + }; +} diff --git a/game/assets/src/GameApp.ts b/game/assets/src/GameApp.ts index 05b2092..5f4b3cc 100644 --- a/game/assets/src/GameApp.ts +++ b/game/assets/src/GameApp.ts @@ -3,7 +3,15 @@ // classes, not cc.Scene assets — see ROADMAP Phase 1. import { Color, EventKeyboard, Input, KeyCode, Label, Node, UITransform, input, view } from "cc"; -import { Creature, QuestionBank, SPECIES_BY_ID, type SaveStateV2 } from "../shared/index"; +import { + Creature, + MEADOW_BADGE_NOTICE, + QuestionBank, + SPECIES_BY_ID, + starterEvolvedNotice, + type SaveStateV2, +} from "../shared/index"; +import { loadQuestionBankData } from "./questions/loadQuestionBank"; import { loadRoutedQuestionBank } from "./questions/loadQuestionBankManifest"; import { NameScreen } from "./NameScreen"; import { Persistence } from "./persistence"; @@ -37,8 +45,11 @@ import { settleArcBattle, } from "./world/arc"; import { + CLOUDMANE_CRITTER_ID, FLAG_TRAIL_STARTED, FLAG_TRAIL_SUMMONED, + GUARDIAN_BANK_PATH, + GUARDIAN_TOPIC, TRAIL_ACCEPTED, TRAIL_CALLED, TrailClue, @@ -104,6 +115,9 @@ export class GameApp { // reset on reload, completion never does. private helpCounts = new Map(); private payoffJustAwarded: string | null = null; + // Meadow finale notices (#23): badge + starter evolution, shown on return + // from the guardian battle. Cleared after display. + private meadowFinaleNotices: string[] = []; // The in-flight scripted arc battle's terminal outcome (#17): onOutcome // always fires before the exit callback, so endArcBattle reads it here. private arcOutcome: BattleOutcome | null = null; @@ -237,6 +251,13 @@ export class GameApp { if (this.banks.has(topic) || this.loadingTopics.has(topic)) return; this.loadingTopics.add(topic); try { + if (topic === GUARDIAN_TOPIC) { + // Fixed multi-topic guardian slate (#23): loaded by path, not through + // the single-topic manifest (the bank mixes 4.1/4.2/4.3/4.4/4.7). + const data = await loadQuestionBankData(GUARDIAN_BANK_PATH); + this.banks.set(topic, new QuestionBank(data)); + return; + } // Routed loading (#13): the active manifest picks the approved bank // version for this curriculum slice — battle code never names a bank // file, and a bad batch is rolled back at the manifest, not here. @@ -408,10 +429,9 @@ export class GameApp { console.warn(`Arc critter names an unknown speciesId: ${critter.speciesId}`); return; } - // The summoned guardian (#21/#22) is ordinary for combat math — the boss - // multi-step path needs banks the routed Std-1 slices don't carry, and - // the fixed guardian slate is #23. Unique pressure comes only from the - // species rarity gate (guardian → trust/flee), never from a UI option. + // Unique pressure comes only from the species rarity gate (guardian → + // trust/flee), never from a UI option. The guardian slate (#23) is served + // in authored order from its multi-topic bank (fixedOrder). const wild = Creature.fromSpecies(species); if (wild.speciesId && this.state.markSeenEntry(wild.speciesId)) { this.checkpoint(); @@ -427,7 +447,10 @@ export class GameApp { onOutcome: (outcome) => { this.arcOutcome = outcome; }, - }, this.telemetry, { rarity: species.rarity }); + }, this.telemetry, { + rarity: species.rarity, + fixedOrder: critter.id === CLOUDMANE_CRITTER_ID, + }); this.canvasNode.addChild(this.battle.root); } @@ -443,6 +466,18 @@ export class GameApp { this.state.setFlag(key, value); } } + // Guardian victory (#23): Meadow Badge once + starter evolution always + // (active or stored). Trust capture also counts — the Unique joined. + if ( + critter.id === CLOUDMANE_CRITTER_ID && + (outcome === "won" || outcome === "captured") + ) { + const finale = this.state.awardMeadowFinale(); + if (finale.badgeAwarded) this.meadowFinaleNotices.push(MEADOW_BADGE_NOTICE); + if (finale.evolvedName) { + this.meadowFinaleNotices.push(starterEvolvedNotice(finale.evolvedName)); + } + } this.returnToWorld(outcome === "defeated"); if (settlement?.penRepaired) { // The payoff (#17): the fence visibly mends and the flock comes home. @@ -455,6 +490,13 @@ export class GameApp { // A wanderer home or the mothling met: refresh the creatures only. this.world.refreshArc(); } + if (this.meadowFinaleNotices.length > 0) { + // Rebuild HUD/portraits so the evolved starter stage shows immediately. + this.world.refreshHud(); + const notice = this.meadowFinaleNotices.join(" "); + this.meadowFinaleNotices = []; + this.world.showNotice(notice); + } this.checkpoint(); } diff --git a/game/assets/src/battle/BattleScreen.ts b/game/assets/src/battle/BattleScreen.ts index d135cda..ad771c9 100644 --- a/game/assets/src/battle/BattleScreen.ts +++ b/game/assets/src/battle/BattleScreen.ts @@ -70,6 +70,12 @@ export interface BattleActions { export interface BattleOptions { /** `guardian` is Meadow's authored Unique; every other rarity stays calm. */ rarity?: SpeciesRarity; + /** + * Serve the bank in authored id order, never random (#23 guardian slate). + * Each Attack advance consumes the next question; after the last item the + * slate wraps so a long Unique hunt never runs dry. + */ + fixedOrder?: boolean; } export class BattleScreen { @@ -88,6 +94,8 @@ export class BattleScreen { private orderingView: OrderingView | null = null; private bossTurns: QuestionTurn[] | null = null; private bossIndex = 0; + /** Next index into a fixedOrder bank (guardian slate, #23). */ + private slateIndex = 0; private switchForced = false; // Null for every ordinary rarity. Unique pressure is battle-local: time // never changes it, only a submitted question does. @@ -160,7 +168,14 @@ export class BattleScreen { private playerAttack(): void { let turn: QuestionTurn; - if (this.wild.boss) { + if (this.options.fixedOrder) { + // Fixed guardian slate (#23): authored order, one item per Attack. + const questions = this.bank.questions; + if (questions.length === 0) throw new Error("BattleScreen: fixedOrder bank is empty"); + const question = questions[this.slateIndex % questions.length]; + this.slateIndex += 1; + turn = turnsOf(question)[0]; + } else if (this.wild.boss) { if (!this.bossTurns || this.bossIndex >= this.bossTurns.length) { const question = this.bank.pick((q) => !!q.steps); this.bossTurns = turnsOf(question); @@ -533,7 +548,9 @@ export class BattleScreen { } private drawCreature(x: number, y: number, creature: Creature, size: number): void { - const node = makeCreaturePortrait(this.root, creature, size); + const stage = + creature === this.state.active ? this.state.activeStage : 1; + const node = makeCreaturePortrait(this.root, { ...creature, stage }, size); node.name = creature.name; node.setPosition(x, y); } diff --git a/game/assets/src/creature-portrait.ts b/game/assets/src/creature-portrait.ts index da37864..9f9188c 100644 --- a/game/assets/src/creature-portrait.ts +++ b/game/assets/src/creature-portrait.ts @@ -3,7 +3,7 @@ // creature-art.ts. One helper so battle, party, and world stay in sync. import { Graphics, Node, Sprite, UITransform } from "cc"; -import { SPECIES_BY_ID } from "../shared/index"; +import { artRefForStage, SPECIES_BY_ID } from "../shared/index"; import { colorFromHex, paintCreature } from "./creature-art"; import { loadPixelTexture, pixelFrame } from "./remote-art"; @@ -11,6 +11,8 @@ export interface PortraitSubject { speciesId?: string | null; color: string; boss: boolean; + /** 1-based evolution stage; omitted/unknown → stage 1 art. */ + stage?: number; } // `size` keeps paintCreature's meaning (body-circle radius); with ears the @@ -19,7 +21,8 @@ export interface PortraitSubject { export function makeCreaturePortrait(parent: Node, subject: PortraitSubject, size: number): Node { const node = new Node("creature-portrait"); node.parent = parent; - const art = subject.speciesId ? SPECIES_BY_ID[subject.speciesId]?.artRef : undefined; + const species = subject.speciesId ? SPECIES_BY_ID[subject.speciesId] : undefined; + const art = species ? artRefForStage(species, subject.stage ?? 1) : undefined; if (!art) { paintCreature(node.addComponent(Graphics), colorFromHex(subject.color), size, subject.boss); return node; diff --git a/game/assets/src/party/PartyScreen.ts b/game/assets/src/party/PartyScreen.ts index 15d589d..ff4d82d 100644 --- a/game/assets/src/party/PartyScreen.ts +++ b/game/assets/src/party/PartyScreen.ts @@ -82,7 +82,11 @@ export class PartyScreen { lineWidth: active ? 3 : undefined, }); - const portrait = makeCreaturePortrait(row, creature, 15); + const portrait = makeCreaturePortrait( + row, + { ...creature, stage: this.state.teamStage(index) }, + 15, + ); portrait.setPosition(-326, -2); const name = makeLabel(row, creature.name, -282, 20, { fontSize: 22, align: "left" }); diff --git a/game/assets/src/sanctuary/SanctuaryScreen.ts b/game/assets/src/sanctuary/SanctuaryScreen.ts index de0b51f..07e51d5 100644 --- a/game/assets/src/sanctuary/SanctuaryScreen.ts +++ b/game/assets/src/sanctuary/SanctuaryScreen.ts @@ -190,7 +190,11 @@ export class SanctuaryScreen { }); row.on(Node.EventType.TOUCH_END, () => this.moveCursor(index)); - const portrait = makeCreaturePortrait(row, creature, 15); + const portrait = makeCreaturePortrait( + row, + { ...creature, stage: creature.stage, boss: false }, + 15, + ); portrait.setPosition(-356, 0); const zh = SPECIES_BY_ID[creature.speciesId]?.nameZh; diff --git a/game/assets/src/state.ts b/game/assets/src/state.ts index 2876e27..9e068c8 100644 --- a/game/assets/src/state.ts +++ b/game/assets/src/state.ts @@ -10,6 +10,7 @@ import { Creature, + awardMeadowFinale, awardPlayerXp, captureCreature, levelForTotalXp, @@ -134,6 +135,32 @@ export class GameState { return true; } + /** + * Meadow guardian victory (#23): award the Meadow Badge once and evolve the + * starter located by `starterCreatureId` whether active or in storage. + * Idempotent — a second victory is a pure no-op. + */ + awardMeadowFinale(): { badgeAwarded: boolean; evolvedName: string | null } { + const result = awardMeadowFinale(this.toSave()); + if (!result.badgeAwarded && result.evolvedName === null) { + return { badgeAwarded: false, evolvedName: null }; + } + this.badges = result.save.badges; + this.ownedCreatures = result.save.ownedCreatures.map((c) => ({ ...c })); + return { badgeAwarded: result.badgeAwarded, evolvedName: result.evolvedName }; + } + + /** 1-based evolution stage for a team slot (defaults to 1). */ + teamStage(index: number): number { + const id = this.teamIds[index]; + return this.ownedCreatures.find((c) => c.creatureId === id)?.stage ?? 1; + } + + /** Evolution stage of the creature currently leading the party. */ + get activeStage(): number { + return this.teamStage(this.activeIndex); + } + /** * World/arc flags (save v2, #17): persistent one-time intention state. * A copy, so arc logic reads plain data; mutate via setFlag + checkpoint. diff --git a/game/assets/src/world/WorldScreen.ts b/game/assets/src/world/WorldScreen.ts index b9481ad..497034b 100644 --- a/game/assets/src/world/WorldScreen.ts +++ b/game/assets/src/world/WorldScreen.ts @@ -756,7 +756,11 @@ export class WorldScreen { this.companionG.ellipse(0, -15, 13, 5); this.companionG.fill(); this.companionPortrait?.destroy(); - this.companionPortrait = makeCreaturePortrait(this.companionNode, this.state.active, 14); + this.companionPortrait = makeCreaturePortrait( + this.companionNode, + { ...this.state.active, stage: this.state.activeStage }, + 14, + ); } private celebrateCompanion() { @@ -1245,7 +1249,11 @@ export class WorldScreen { lineWidth: 3, }, ); - const portrait = makeCreaturePortrait(card, creature, 16); + const portrait = makeCreaturePortrait( + card, + { ...creature, stage: this.state.activeStage }, + 16, + ); portrait.setPosition(-69, -1); const name = makeLabel(card, creature.name, -43, 15, { fontSize: 17, align: "left" }); diff --git a/game/assets/src/world/trail.ts b/game/assets/src/world/trail.ts index 351f722..e89abb5 100644 --- a/game/assets/src/world/trail.ts +++ b/game/assets/src/world/trail.ts @@ -13,9 +13,10 @@ // // Scope: this module owns the trail only. Unique flee pressure and trust // capture (#22) live in shared/battle-rules + BattleScreen and key off the -// guardian rarity; the Meadow Badge, fixed question slate, and starter -// evolution are #23. Until #23 the summit battle draws from the routed 4.1 -// bank like any scripted beat. +// guardian rarity. The Meadow Badge, fixed multi-topic question slate, and +// starter evolution (#23) settle on victory in GameApp via meadow-finale.ts. +// The summit battle serves the hand-checked guardian bank in fixed order +// (not a routed single-topic slice). // // Pure and Node-testable (type-only imports, like arc.ts): WorldScreen and // GameApp only render and route it; game/tests/trail.test.ts pins the logic. @@ -97,11 +98,21 @@ export const GUARDIAN_SPECIES_ID = "meadow/cloudmane"; export const GUARDIAN_SPOT = { x: 13, y: 6 } as const; export const CLOUDMANE_CRITTER_ID = "cloudmane-summit"; +/** + * Load key for the fixed multi-topic guardian slate. Not a curriculum topic + * (QUESTION_TOPICS) — GameApp loads GUARDIAN_BANK_PATH directly because the + * bank deliberately mixes 4.1 / 4.2 / 4.3 / 4.4 / 4.7 and cannot route through + * the single-topic manifest. + */ +export const GUARDIAN_TOPIC = "guardian"; +/** Cocos resources path (no extension) of the hand-checked guardian bank. */ +export const GUARDIAN_BANK_PATH = "question-banks/std1/std1.meadow-guardian.v1"; + /** * The summoned guardian, visible in its region. It stays put through every * battle outcome — flee, defeat, even victory — so a second (third, tenth) * chance is always standing in the ring — even after a Unique escape (#22) - * or a victory. #23 owns the badge/evolution payoff. + * or a victory. Badge/evolution payoffs (#23) are idempotent on re-battle. */ export function trailCrittersFor(regionId: string, flags: Record): ArcCritter[] { if (regionId !== "meadow/stones" || !flags[FLAG_TRAIL_SUMMONED]) return []; @@ -110,9 +121,7 @@ export function trailCrittersFor(regionId: string, flags: Record id: CLOUDMANE_CRITTER_ID, ...GUARDIAN_SPOT, speciesId: GUARDIAN_SPECIES_ID, - // The fixed multi-topic guardian slate is #23; until then the summit - // battle draws from the routed counting bank like any scripted beat. - topic: "4.1", + topic: GUARDIAN_TOPIC, kind: "battle", capturable: false, }, diff --git a/game/tests/trail.test.ts b/game/tests/trail.test.ts index 58d1c8b..a469342 100644 --- a/game/tests/trail.test.ts +++ b/game/tests/trail.test.ts @@ -12,8 +12,10 @@ import { CLOUDMANE_CRITTER_ID, FLAG_TRAIL_STARTED, FLAG_TRAIL_SUMMONED, + GUARDIAN_BANK_PATH, GUARDIAN_SPECIES_ID, GUARDIAN_SPOT, + GUARDIAN_TOPIC, TRAIL_CLUES, nextTrailClue, trailBattleTopicsFor, @@ -128,9 +130,9 @@ test("trail: the waiting guardian is the standing second chance", () => { } }); -test("trail: battle topics stay on routed slices", () => { +test("trail: battle topics name the guardian slate load key after the Call", () => { assert.deepEqual(trailBattleTopicsFor("meadow/stones", {}), []); - assert.deepEqual(trailBattleTopicsFor("meadow/stones", SUMMONED), ["4.1"]); + assert.deepEqual(trailBattleTopicsFor("meadow/stones", SUMMONED), [GUARDIAN_TOPIC]); assert.deepEqual(trailBattleTopicsFor("harbor", SUMMONED), []); }); @@ -199,3 +201,13 @@ test("trail: the guardian is never in a wild table (no random low-probability hu ); } }); + +// --- guardian slate routing (#23) --------------------------------------------- + +test("trail: the summoned guardian names the multi-topic slate load key", () => { + const [guardian] = trailCrittersFor("meadow/stones", SUMMONED); + assert.equal(guardian.topic, GUARDIAN_TOPIC); + assert.equal(GUARDIAN_TOPIC, "guardian"); + assert.equal(GUARDIAN_BANK_PATH, "question-banks/std1/std1.meadow-guardian.v1"); + assert.deepEqual(trailBattleTopicsFor("meadow/stones", SUMMONED), [GUARDIAN_TOPIC]); +}); diff --git a/shared/creature.ts b/shared/creature.ts index 0e45614..b502cb4 100644 --- a/shared/creature.ts +++ b/shared/creature.ts @@ -120,6 +120,20 @@ export function isStarterId(value: unknown): value is string { return typeof value === "string" && STARTERS.some((s) => s.id === value); } +/** + * The art cell for a 1-based evolution stage. Stage-1 art is the species' + * registered `artRef` (which may already offset past an empty leading pack + * cell); later stages step right by one cell width. Clamps to [1, stages]. + * Missing art stays undefined so portraits fall back to the blob. + */ +export function artRefForStage(species: Species, stage: number): SpeciesArt | undefined { + const art = species.artRef; + if (!art) return undefined; + const clamped = Math.max(1, Math.min(Math.floor(stage) || 1, species.stages)); + if (clamped === 1) return art; + return { ...art, x: art.x + (clamped - 1) * art.w }; +} + // Legacy prototype species (pre-registry). Kept registered so old save // records that reference them still resolve; they have no Chinese name and // no habitat — the Meadow slate below is the roster that matters. diff --git a/shared/index.ts b/shared/index.ts index 3b26f65..5c86b95 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -24,4 +24,5 @@ export * from "./save-v2.ts"; export * from "./save-v2-validate.ts"; export * from "./save-migrate.ts"; export * from "./habitats.ts"; +export * from "./meadow-finale.ts"; export * from "./telemetry.ts"; diff --git a/shared/meadow-finale.ts b/shared/meadow-finale.ts new file mode 100644 index 0000000..436ee9e --- /dev/null +++ b/shared/meadow-finale.ts @@ -0,0 +1,72 @@ +// Meadow Isle finale (M6, #23): the Meadow Badge and guaranteed starter +// evolution after the Cloud-Maned Horse guardian battle. +// +// Design authority: docs/islands/meadow-isle.md §8 + locked decisions — +// - Victory awards the Meadow Badge exactly once and persists it. +// - The starter identified by `starterCreatureId` evolves whether active +// or stored, with no FOMO condition. +// +// Pure domain: GameState applies the result; World/GameApp only show notices. + +import { SPECIES_BY_ID } from "./creature.ts"; +import type { OwnedCreatureState, SaveStateV2 } from "./save-v2.ts"; + +/** The island badge id on save v2 `badges`. Awarded once, never revoked. */ +export const MEADOW_BADGE = "meadow-badge"; + +/** Stage the Meadow Badge evolves the starter to (1-based). */ +export const MEADOW_STARTER_EVOLUTION_STAGE = 2; + +/** Shown the first time the Meadow Badge is earned. */ +export const MEADOW_BADGE_NOTICE = + "You earned the Meadow Badge! The island remembers your kindness. " + + "获得了牧场徽章!整座岛都记得你的温柔。"; + +/** Shown when the starter's stage advances with the badge. */ +export function starterEvolvedNotice(name: string): string { + return ( + `${name} is glowing — it evolved! ${name} 身上亮起了光——它进化了!` + ); +} + +export interface MeadowFinaleResult { + readonly save: SaveStateV2; + /** True only the first time the badge is written. */ + readonly badgeAwarded: boolean; + /** The starter's display name when its stage just advanced; null otherwise. */ + readonly evolvedName: string | null; +} + +/** + * Apply the Hundred Stones victory payoffs to a save: + * 1. Award `meadow-badge` if not already held. + * 2. Evolve the starter located by `starterCreatureId` to stage 2 when the + * species has a second stage and the starter is still stage 1. + * + * Idempotent: a second victory is a pure no-op (badge stays once; stage stays + * at 2). Never mutates its input. + */ +export function awardMeadowFinale(save: SaveStateV2): MeadowFinaleResult { + const badgeAwarded = !save.badges.includes(MEADOW_BADGE); + const badges = badgeAwarded ? [...save.badges, MEADOW_BADGE] : save.badges; + + let evolvedName: string | null = null; + const ownedCreatures: OwnedCreatureState[] = save.ownedCreatures.map((creature) => { + if (creature.creatureId !== save.starterCreatureId) return creature; + if (creature.stage >= MEADOW_STARTER_EVOLUTION_STAGE) return creature; + const species = SPECIES_BY_ID[creature.speciesId]; + if (!species || species.stages < MEADOW_STARTER_EVOLUTION_STAGE) return creature; + evolvedName = creature.name; + return { ...creature, stage: MEADOW_STARTER_EVOLUTION_STAGE }; + }); + + if (!badgeAwarded && evolvedName === null) { + return { save, badgeAwarded: false, evolvedName: null }; + } + + return { + save: { ...save, badges, ownedCreatures }, + badgeAwarded, + evolvedName, + }; +} diff --git a/shared/tests/meadow-finale.test.ts b/shared/tests/meadow-finale.test.ts new file mode 100644 index 0000000..04339bc --- /dev/null +++ b/shared/tests/meadow-finale.test.ts @@ -0,0 +1,107 @@ +// Meadow finale (#23): Meadow Badge once + starter evolution always. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { artRefForStage, STARTERS, SPECIES_BY_ID } from "../creature.ts"; +import { + MEADOW_BADGE, + MEADOW_STARTER_EVOLUTION_STAGE, + awardMeadowFinale, +} from "../meadow-finale.ts"; +import { createNewGameV2 } from "../save-v2.ts"; + +test("awardMeadowFinale: first victory awards the badge and evolves the starter", () => { + const save = createNewGameV2(STARTERS[0]); + const starter = save.ownedCreatures.find((c) => c.creatureId === save.starterCreatureId)!; + assert.equal(starter.stage, 1); + assert.equal(save.badges.includes(MEADOW_BADGE), false); + + const result = awardMeadowFinale(save); + assert.equal(result.badgeAwarded, true); + assert.equal(result.evolvedName, starter.name); + assert.ok(result.save.badges.includes(MEADOW_BADGE)); + const evolved = result.save.ownedCreatures.find((c) => c.creatureId === save.starterCreatureId)!; + assert.equal(evolved.stage, MEADOW_STARTER_EVOLUTION_STAGE); + // Input is never mutated. + assert.equal(save.badges.includes(MEADOW_BADGE), false); + assert.equal(starter.stage, 1); +}); + +test("awardMeadowFinale: starter evolves whether active or stored", () => { + const save = createNewGameV2(STARTERS[1]); // lumentail + // Capture a second friend onto the team, then leave the starter in storage. + const friendId = "friend-1"; + const withFriend = { + ...save, + ownedCreatures: [ + ...save.ownedCreatures, + { + creatureId: friendId, + speciesId: "woolly/fluffball", + stage: 1, + variant: "normal", + name: "Fluffball", + color: "#ede0c8", + maxHp: 13, + hp: 13, + attack: 2, + level: 1, + xp: 0, + boss: false, + }, + ], + teamIds: [friendId], + activeTeamId: friendId, + }; + assert.equal(withFriend.teamIds.includes(withFriend.starterCreatureId), false); + + const result = awardMeadowFinale(withFriend); + assert.equal(result.badgeAwarded, true); + assert.equal(result.evolvedName, STARTERS[1].name); + const starter = result.save.ownedCreatures.find( + (c) => c.creatureId === withFriend.starterCreatureId, + )!; + assert.equal(starter.stage, MEADOW_STARTER_EVOLUTION_STAGE); + // Team composition is untouched — evolution is identity, not team state. + assert.deepEqual(result.save.teamIds, [friendId]); + assert.equal(result.save.activeTeamId, friendId); +}); + +test("awardMeadowFinale: second victory is a pure no-op", () => { + const once = awardMeadowFinale(createNewGameV2(STARTERS[2])); + const twice = awardMeadowFinale(once.save); + assert.equal(twice.badgeAwarded, false); + assert.equal(twice.evolvedName, null); + assert.equal(twice.save, once.save); + const starter = twice.save.ownedCreatures.find( + (c) => c.creatureId === twice.save.starterCreatureId, + )!; + assert.equal(starter.stage, MEADOW_STARTER_EVOLUTION_STAGE); + assert.deepEqual(twice.save.badges, [MEADOW_BADGE]); +}); + +test("awardMeadowFinale: works for every starter species", () => { + for (const starter of STARTERS) { + const result = awardMeadowFinale(createNewGameV2(starter)); + assert.equal(result.badgeAwarded, true, starter.id); + assert.equal(result.evolvedName, starter.name, starter.id); + const evolved = result.save.ownedCreatures[0]; + assert.equal(evolved.stage, MEADOW_STARTER_EVOLUTION_STAGE, starter.id); + assert.ok(starter.stages >= MEADOW_STARTER_EVOLUTION_STAGE, starter.id); + } +}); + +test("artRefForStage: stage 1 is the registered cell; stage 2 steps right one cell", () => { + const sprout = SPECIES_BY_ID.sproutkit; + assert.ok(sprout.artRef); + const s1 = artRefForStage(sprout, 1)!; + const s2 = artRefForStage(sprout, 2)!; + assert.equal(s1.x, sprout.artRef!.x); + assert.equal(s2.x, sprout.artRef!.x + sprout.artRef!.w); + assert.equal(s2.y, sprout.artRef!.y); + assert.equal(s2.sheet, sprout.artRef!.sheet); + // Clamps to the family's stage count. + const past = artRefForStage(sprout, 99)!; + assert.equal(past.x, sprout.artRef!.x + (sprout.stages - 1) * sprout.artRef!.w); +}); diff --git a/shared/tests/meadow-guardian-bank.test.ts b/shared/tests/meadow-guardian-bank.test.ts new file mode 100644 index 0000000..fc6e4c5 --- /dev/null +++ b/shared/tests/meadow-guardian-bank.test.ts @@ -0,0 +1,79 @@ +// Fixed guardian slate bank (#23): one hand-checked item per locked topic, +// deterministic order, mostly TP3–4 with exactly one TP5 twist. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { parseQuestionBankData } from "../question-bank-validate.ts"; +import { QuestionBank, turnsOf } from "../question-engine.ts"; + +const BANK_URL = new URL( + "../../game/assets/resources/question-banks/std1/std1.meadow-guardian.v1.json", + import.meta.url, +); + +const LOCKED_TOPICS = ["4.1", "4.2", "4.4", "4.3", "4.7"] as const; +const TOPIC_LABEL: Record<(typeof LOCKED_TOPICS)[number], string> = { + "4.1": "counting", + "4.2": "addition/subtraction", + "4.4": "clock", + "4.3": "money", + "4.7": "pictograph", +}; + +const RAW = JSON.parse(await readFile(BANK_URL, "utf8")); +const BANK = parseQuestionBankData(RAW); + +test("guardian bank: parses as schema v2 and is the hand-checked slate", () => { + assert.equal(BANK.schema_version, 2); + assert.equal(BANK.bank_id, "std1.meadow-guardian"); + assert.equal(BANK.version, 1); + assert.equal(BANK.questions.length, 5); +}); + +test("guardian bank: one item per locked topic, in the finale order", () => { + assert.deepEqual( + BANK.questions.map((q) => q.topic), + [...LOCKED_TOPICS], + ); + for (const topic of LOCKED_TOPICS) { + const hits = BANK.questions.filter((q) => q.topic === topic); + assert.equal(hits.length, 1, `exactly one ${TOPIC_LABEL[topic]} item`); + } +}); + +test("guardian bank: mostly TP3–4 with at most one TP5 twist", () => { + const tps = BANK.questions.map((q) => q.tp_level!); + const tp5 = tps.filter((tp) => tp === 5); + assert.ok(tp5.length <= 1, `at most one TP5, got ${tp5.length}`); + for (const tp of tps) { + assert.ok(tp >= 3 && tp <= 5, `tp ${tp} outside 3–5`); + } + // The pictograph (4.7) is the authored TP5 twist. + const picto = BANK.questions.find((q) => q.topic === "4.7")!; + assert.equal(picto.tp_level, 5); +}); + +test("guardian bank: topic presentations match the locked slate roles", () => { + const byTopic = Object.fromEntries(BANK.questions.map((q) => [q.topic, q])); + assert.equal(byTopic["4.1"].operation, "counting"); + assert.ok( + byTopic["4.2"].operation === "addition" || byTopic["4.2"].operation === "subtraction", + ); + assert.equal(byTopic["4.4"].presentation, "figure:clock"); + assert.equal(byTopic["4.3"].answer_unit, "RM"); + assert.equal(byTopic["4.7"].presentation, "figure:pictograph"); +}); + +test("guardian bank: fixedOrder serving walks ids 1..5 then wraps", () => { + const bank = new QuestionBank(BANK); + const served: number[] = []; + for (let i = 0; i < 7; i++) { + const q = bank.questions[i % bank.questions.length]; + served.push(q.id); + // Every item is a single turn (Std-1 single-step). + assert.equal(turnsOf(q).length, 1); + } + assert.deepEqual(served, [1, 2, 3, 4, 5, 1, 2]); +}); From b47dd0bf05f2e595a1ad8221acf19062203826a9 Mon Sep 17 00:00:00 2001 From: Season Date: Mon, 20 Jul 2026 10:50:53 +0800 Subject: [PATCH 2/2] Add Cocos .meta for synced meadow-finale --- game/assets/shared/meadow-finale.ts.meta | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 game/assets/shared/meadow-finale.ts.meta diff --git a/game/assets/shared/meadow-finale.ts.meta b/game/assets/shared/meadow-finale.ts.meta new file mode 100644 index 0000000..72650a9 --- /dev/null +++ b/game/assets/shared/meadow-finale.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "62490751-148a-40e3-8b33-c159f235308f", + "files": [], + "subMetas": {}, + "userData": {} +}