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>
118 lines
5.2 KiB
TypeScript
118 lines
5.2 KiB
TypeScript
// Assemble: "Get a random minion. Change half its text."
|
|
//
|
|
// Card text is a list of clauses, so changing half of it is list surgery: the base
|
|
// card's `text.slice(0, split)` (top) or `text.slice(split)` (bottom) is replaced by
|
|
// the same half of a donor card. Name, cost and stats stay the base's. This works
|
|
// only because a card's behaviour is its data: the new card needs no new code.
|
|
//
|
|
// 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, wellFormed, type CardDef, type PoolFilter } from "./card";
|
|
import type { EntityId, PlayerId, World } from "./world";
|
|
|
|
export type Half = "top" | "bottom";
|
|
const HALVES: Half[] = ["top", "bottom"];
|
|
|
|
/** 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,
|
|
) {
|
|
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";
|
|
}
|
|
}
|
|
|
|
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
|
|
const canonical = (value: unknown): string =>
|
|
JSON.stringify(value, (_key, v) =>
|
|
v && typeof v === "object" && !Array.isArray(v)
|
|
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
: v,
|
|
);
|
|
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
|
|
/** Two definitions the engine can't tell apart (it ignores `fictional` and `game`). */
|
|
const sameCard = (a: CardDef, b: CardDef): boolean => {
|
|
const read = (d: CardDef) =>
|
|
canonical([d.name, d.type, d.cost, d.attack ?? 0, d.health ?? 0, d.noun ?? null, d.text, d.split]);
|
|
return read(a) === read(b);
|
|
};
|
|
|
|
/**
|
|
* The base card with `half` of its text replaced by the same half of `donor`.
|
|
* It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
|
*/
|
|
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
|
|
const topOf = (c: CardDef) => c.text.slice(0, c.split);
|
|
const bottomOf = (c: CardDef) => c.text.slice(c.split);
|
|
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, "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);
|
|
return true;
|
|
} catch (e) {
|
|
if (e instanceof AssembleError) return false;
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Register an assembled card and return its name. If a different card already has
|
|
* the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
|
*/
|
|
export function registerAssembled(world: World, def: CardDef): string {
|
|
const stem = def.name.replace(/\)$/, "");
|
|
let name = def.name;
|
|
// A different card is any difference the engine reads: the text, the split, or a base re-registered since.
|
|
for (let n = 2; world.hasCard(name) && !sameCard(world.card(name).def, { ...def, name }); n++) {
|
|
name = `${stem} ${n})`;
|
|
}
|
|
world.registerAssembled({ ...def, name });
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* The `assemble` effect: the owner's chooser picks a base minion from the pool
|
|
* (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 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 donor = world.card(world.choose("donor", donorsFor(base))).def;
|
|
const half = world.choose(
|
|
"half",
|
|
HALVES.filter((h) => changes(base, donor, h)),
|
|
) as Half;
|
|
const name = registerAssembled(world, assemble(base, donor, half));
|
|
return world.create(name, player, "hand");
|
|
}
|