Files
systemic-game-design-course/tools/mutants.json
T
Lucas WintherandClaude Opus 5.5 78862d1a1f feat: mutation check, scenario runner and designer capstone; CI jobs; Module 13 brought up to date
- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the
  Module 12 TypeScript reference (the eight of Module 13's first table, plus
  Armor before redirect, conditions at parse time, no defender priority, and the
  summon seam's noun check); all killed.
- tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words
  and seam words as data, played on the Module 10 TypeScript reference.
- course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt
  is checked against the tool's output in the tools tests).
- CI: diff-tracks (300 seeds), mutation-check, verify-levels.
- Module 13: the mutation table is the tool's output; differential testing,
  undo and the solver sections describe the repository as it is. Lesson 10
  says what the level proofs prove for levels 6, 8 and 9.
- schema.mjs: a value that matches no oneOf branch is reported against the
  branch its const fields pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:58:45 +01:00

132 lines
6.3 KiB
JSON

[
{
"name": "End of Phase: Aura Update before the Death Creation Step",
"file": "src/engine/queue.ts",
"find": "export const END_OF_PHASE: readonly EndOfPhaseStep[] = [\"deathCreation\", \"auraUpdate\"];",
"replace": "export const END_OF_PHASE: readonly EndOfPhaseStep[] = [\"auraUpdate\", \"deathCreation\"];",
"expect": [
"test_04_simultaneous_death_not_doubled",
"test_04_aura_timing_order",
"test_10_level_3_solution",
"test_10_level_3_naive_would_differ"
]
},
{
"name": "Turn: no parse between movement and conversion",
"file": "src/engine/puzzle/turn.ts",
"find": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"replace": "export const TURN: readonly Phase[] = [\"movement\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"expect": [
"test_09_mid_turn_conversion",
"test_10_level_5_solution"
]
},
{
"name": "Turn: win check before hazards",
"file": "src/engine/puzzle/turn.ts",
"find": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"replace": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"win\", \"hazards\", \"parse\"];",
"expect": [
"test_09_defeat_beats_win",
"test_10_level_2_solution"
]
},
{
"name": "Turn: no parse between conversion and hazards",
"file": "src/engine/puzzle/turn.ts",
"find": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"replace": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"hazards\", \"win\", \"parse\"];",
"expect": [
"test_09_conversion_reparses_before_hazards"
]
},
{
"name": "Turn: hazards before conversion",
"file": "src/engine/puzzle/turn.ts",
"find": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"replace": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"hazards\", \"parse\", \"conversion\", \"win\", \"parse\"];",
"expect": [
"test_09_conversion_before_hazards",
"test_09_conversion_reparses_before_hazards",
"test_09_undo_restores_everything"
]
},
{
"name": "Turn: no last parse",
"file": "src/engine/puzzle/turn.ts",
"find": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\", \"parse\"];",
"replace": "export const TURN: readonly Phase[] = [\"movement\", \"parse\", \"conversion\", \"parse\", \"hazards\", \"win\"];",
"expect": [
"test_09_last_parse_matches_board"
]
},
{
"name": "Hazards in reverse order",
"file": "src/content/hazards.ts",
"find": "return [sink, hotMelt, defeat];",
"replace": "return [defeat, hotMelt, sink];",
"expect": [
"test_09_hazard_order_melt_before_defeat",
"test_09_hazard_order_sink_before_melt",
"test_10_level_6_solution",
"test_11_condition_rechecked_between_hazards"
]
},
{
"name": "Hazards: DEFEAT before HOT/MELT",
"file": "src/content/hazards.ts",
"find": "return [sink, hotMelt, defeat];",
"replace": "return [sink, defeat, hotMelt];",
"expect": [
"test_09_hazard_order_melt_before_defeat",
"test_10_level_6_solution"
]
},
{
"name": "Auras read live from the board, no snapshot",
"file": "src/engine/world.ts",
"find": " activeAuras(): readonly ActiveAura[] {\n return this.auras;\n }",
"replace": " activeAuras(): readonly ActiveAura[] {\n // MUTANT: every minion in play now, and the rule auras, read at each use.\n return [\n ...this.minionsInPlay().flatMap((id) => this.card(this.cardName(id)).auras.map((aura) => ({ owner: this.ownerOf(id), source: id, aura }))),\n ...this.rules.flatMap((aura) => ([0, 1] as const).map((owner) => ({ owner, aura }))),\n ];\n }",
"expect": [
"test_05_own_aura_misses_own_battlecry",
"test_10_level_3_naive_would_differ"
]
},
{
"name": "Damage: Armor before redirect",
"file": "src/engine/queue.ts",
"find": "export const DAMAGE_STEPS: readonly DamageStep[] = [\"redirect\", \"armor\"];",
"replace": "export const DAMAGE_STEPS: readonly DamageStep[] = [\"armor\", \"redirect\"];",
"expect": [
"test_04_redirect_before_armor"
]
},
{
"name": "Conditions decided at parse time",
"file": "src/engine/puzzle/level.ts",
"find": " this.compiled = parse(this.board, this.lexicon);\n this.active = resolve(this.compiled);\n }",
"replace": " this.compiled = parse(this.board, this.lexicon);\n this.active = resolve(this.compiled);\n // MUTANT: decide every condition now, for every object, and answer from that table until the next parse.\n const self = this as unknown as { live?: ConditionChecks; conditions: ConditionChecks };\n const live = (self.live ??= self.conditions);\n const decided = new Map<string, boolean>();\n const key = (word: string, id: ObjectId, nouns: readonly string[]) => `${word} ${nouns.join(\" \")} ${id}`;\n for (const o of this.board.objects()) {\n for (const r of this.active) for (const c of r.conditions) decided.set(key(c.word, o.id, c.nouns), live[c.word](this, o.id, c.nouns));\n }\n self.conditions = Object.fromEntries(\n Object.keys(live).map((word) => [word, (_: Level, id: ObjectId, nouns: readonly string[]) => decided.get(key(word, id, nouns)) ?? false]),\n );\n }",
"expect": [
"test_11_condition_rechecked_between_hazards"
]
},
{
"name": "Combat: no defender priority",
"file": "src/engine/queue.ts",
"find": " queue.sort((a, b) => b[4] - a[4]);\n",
"replace": " // MUTANT: no sort; the Queue keeps the order it was filled in.\n",
"expect": [
"test_12_defender_triggers_first"
]
},
{
"name": "SpellGrid: a summoned minion without a noun enters the grid",
"file": "src/engine/spellgrid.ts",
"find": " if (this.ids.has(event.id) || this.world.card(card).def.noun === undefined) continue;\n",
"replace": " if (this.ids.has(event.id)) continue;\n",
"expect": [
"test_10_summon_without_noun_stays_off_grid"
]
}
]