feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)

A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 11:07:54 +01:00
1 parent 372c99c306
commit 21fb40c6f5
81 files changed
+1880 -528

No files matched your search

@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),