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:
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)),
|
||||
|
||||
@@ -143,6 +143,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.
|
||||
@@ -40,7 +42,8 @@ export {
|
||||
type Target,
|
||||
type Trigger,
|
||||
type TriggerOn,
|
||||
wellFormed,
|
||||
} from "./card";
|
||||
export type { Event } from "./event";
|
||||
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
|
||||
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
|
||||
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -20,6 +20,11 @@
|
||||
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
|
||||
// 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.
|
||||
|
||||
import { assembleFromPool } from "./assemble";
|
||||
import { activations, auraUpdateOther, redirectFor } from "./aura";
|
||||
@@ -91,6 +96,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);
|
||||
@@ -98,6 +109,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);
|
||||
@@ -214,16 +229,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);
|
||||
@@ -234,7 +253,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();
|
||||
}
|
||||
|
||||
@@ -49,8 +49,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. */
|
||||
@@ -226,17 +230,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. */
|
||||
|
||||
Reference in new issue
Block a user