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