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)),
@@ -150,6 +150,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -33,7 +35,7 @@
// the end-of-Phase steps as a list (`END_OF_PHASE`); Part II gains a `Destroy` hook.
export * as puzzle from "./puzzle";
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
export { AssembleError, assemble, registerAssembled, type AssembleFailure, type Half } from "./assemble";
export {
CardLibrary,
triggerLabel,
@@ -46,12 +48,14 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { auraUpdateOther } from "./aura";
export {
Ctx,
DAMAGE_STEPS,
DEATH_PHASE_LIMIT,
END_OF_PHASE,
runSequence,
summon,
@@ -21,6 +21,11 @@
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`,
// read from the world, as Part II's turn is a list of phases. SpellGrid's tests run
// the same Sequence with the list reversed, to show what the order decides.
@@ -111,6 +116,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -118,6 +129,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -245,16 +260,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -265,7 +284,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -53,8 +53,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -252,17 +256,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */