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>
This commit is contained in:
1 parent
c8fff05ccb
commit
fb9d0a4eb4
89 files changed
+11852
-6
No files matched your search
@@ -0,0 +1,117 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in new issue
Block a user