Files
systemic-game-design-course/course/12-combat/reference/ts/src/engine/assemble.ts
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks,
the cumulative reference (Module 11's plus combat) and the drop. The Damage
action carries per-target hits with a priority; react stable-sorts its Queue by
priority before order of play, so the triggers answering the defender's Damage
Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack
because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari
Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness
contract, catalog, research notes and earlier lessons' forward pointers follow.

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

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");
}