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>
203 lines
7.5 KiB
TypeScript
203 lines
7.5 KiB
TypeScript
// Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
|
// read straight from JSON. The engine compiles each definition once, when it is
|
|
// registered, into the lists it reads while resolving: triggers, auras and a
|
|
// spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
|
|
|
/** What a triggered ability listens for. */
|
|
export type TriggerOn =
|
|
| "selfDamaged" // this minion takes damage
|
|
| "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage
|
|
| "anyMinionDamaged" // any minion takes damage
|
|
| "heroDamaged" // either hero takes damage
|
|
| "deathrattle" // this minion dies (it resolves after the minion has left play)
|
|
| "battlecry" // this minion is played from hand
|
|
| "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires
|
|
|
|
/** The trigger's family name, as reported in `Triggered` events. */
|
|
export function triggerLabel(on: TriggerOn): string {
|
|
switch (on) {
|
|
case "selfDamaged":
|
|
case "friendlyMinionDamaged":
|
|
case "anyMinionDamaged":
|
|
case "heroDamaged":
|
|
return "damaged";
|
|
case "deathrattle":
|
|
case "battlecry":
|
|
case "endOfTurn":
|
|
return on;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An aura: a continuous effect a minion has while it is in play. The engine reads
|
|
* auras only at the Aura Update points (see `aura.ts`), never mid-Phase.
|
|
*
|
|
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
|
|
* the aura owner's owner, activate `times` times each. `stacking` says how several
|
|
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
|
|
* a doubler still double); `"additive"` adds each copy's extra activations.
|
|
*
|
|
* `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
|
|
* the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
|
|
*/
|
|
export type Aura =
|
|
| { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
|
|
| { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
|
|
|
|
/** Who an effect touches. */
|
|
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
|
|
|
|
/** Which minions an Assemble may pick as its base. An empty filter allows any. */
|
|
export interface PoolFilter {
|
|
/** Only minions with a trigger clause listening for this. */
|
|
trigger?: TriggerOn;
|
|
}
|
|
|
|
/** One effect: a node of a card's ability, interpreted by `effect.ts`. */
|
|
export type Effect =
|
|
| { type: "dealDamage"; target: Target; amount: number }
|
|
| { type: "draw"; count: number }
|
|
| { type: "summon"; card: string; count: number }
|
|
| { type: "gainArmor"; amount: number }
|
|
| { type: "gainAttack"; target: Target; amount: number }
|
|
| { type: "destroy"; target: Target }
|
|
/** Part III (SpellGrid) only: puts a word on the grid. */
|
|
| { type: "spawnText"; word: string; at: "self" }
|
|
/** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */
|
|
| { type: "assemble"; filter: PoolFilter }
|
|
/** Give every minion in the owner's hand +attack/+health. */
|
|
| { type: "buffHand"; attack: number; health: number };
|
|
|
|
/**
|
|
* One line of card text. A trigger clause applies to the effect clauses after it,
|
|
* until the next trigger, keyword or aura. Effects before any trigger are what a
|
|
* spell does when cast.
|
|
*/
|
|
export type Clause =
|
|
| { kind: "keyword"; keyword: string }
|
|
| { kind: "trigger"; on: TriggerOn }
|
|
| { kind: "aura"; modifier: Aura }
|
|
| { kind: "effect"; effect: Effect };
|
|
|
|
/** A card as data, in the course's card format. */
|
|
export interface CardDef {
|
|
name: string;
|
|
type: "minion" | "spell";
|
|
cost: number;
|
|
attack?: number;
|
|
health?: number;
|
|
noun?: string;
|
|
text: Clause[];
|
|
/** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */
|
|
split: number;
|
|
fictional?: boolean;
|
|
game?: string;
|
|
}
|
|
|
|
/** A triggered ability: "whenever <on>, <effects>". */
|
|
export interface Trigger {
|
|
on: TriggerOn;
|
|
effects: Effect[];
|
|
}
|
|
|
|
/** A registered card: its definition, and the definition's text compiled into the lists the engine reads. */
|
|
export interface Card {
|
|
def: CardDef;
|
|
/** Triggered abilities, in printed order. */
|
|
triggers: Trigger[];
|
|
/** Auras this minion has while in play. */
|
|
auras: Aura[];
|
|
/** What the card does when cast (a spell's effects). */
|
|
onCast: Effect[];
|
|
/** May an Assemble offer this card? Minions with text that were not themselves assembled. */
|
|
inPool: boolean;
|
|
}
|
|
|
|
/** Compile a definition's text. Throws if `split` is past the end of the text. */
|
|
export function compile(def: CardDef, inPool: boolean): Card {
|
|
if (def.split > def.text.length) throw new Error(`"${def.name}": split ${def.split} is past the end of its text`);
|
|
const card: Card = { def, triggers: [], auras: [], onCast: [], inPool: false };
|
|
// The trigger whose effects are still being listed, if any.
|
|
let open: Trigger | undefined;
|
|
for (const clause of def.text) {
|
|
switch (clause.kind) {
|
|
case "trigger":
|
|
open = { on: clause.on, effects: [] };
|
|
card.triggers.push(open);
|
|
break;
|
|
case "effect":
|
|
(open ? open.effects : card.onCast).push(clause.effect);
|
|
break;
|
|
case "aura":
|
|
card.auras.push(clause.modifier);
|
|
open = undefined;
|
|
break;
|
|
case "keyword": // The course has no combat, so keywords such as Taunt do nothing here.
|
|
open = undefined;
|
|
break;
|
|
}
|
|
}
|
|
card.inPool = inPool && def.type === "minion" && def.text.length > 0;
|
|
return 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>();
|
|
|
|
constructor(defs: CardDef[]) {
|
|
for (const def of defs) this.register(def);
|
|
}
|
|
|
|
/** Add a card, or replace the card with the same name. It joins the Assemble pool if it is a minion with text. */
|
|
register(def: CardDef): void {
|
|
this.cards.set(def.name, compile(def, true));
|
|
}
|
|
|
|
/** Add a card made by Assemble. It never joins the pool. */
|
|
registerAssembled(def: CardDef): void {
|
|
this.cards.set(def.name, compile(def, false));
|
|
}
|
|
|
|
has(name: string): boolean {
|
|
return this.cards.has(name);
|
|
}
|
|
|
|
get(name: string): Card {
|
|
const card = this.cards.get(name);
|
|
if (!card) throw new Error(`unknown card "${name}": add its file to cards/`);
|
|
return card;
|
|
}
|
|
|
|
/** The Assemble pool: every card with `inPool`, sorted by name. */
|
|
pool(): Card[] {
|
|
return [...this.cards.values()]
|
|
.filter((c) => c.inPool)
|
|
.sort((a, b) => (a.def.name < b.def.name ? -1 : a.def.name > b.def.name ? 1 : 0));
|
|
}
|
|
}
|