First Part II module. The cumulative reference adds a second engine (src/engine/puzzle: grid, lexer, parser) beside Part I's; nouns and properties are data in src/content/words.json, and the engine names only IS/AND/NOT/TEXT and the base rule TEXT IS PUSH. Rules are recompiled from the board on every change; rules() is a sorted set. Adds the M6 scenarios and tests (rows, columns, shared IS, AND distribution, stacked text, base rule), the HOT word as a data-only Expansion Drop, the lesson with the Part II bridge, and the Rust LevelDef shape in harness.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
6.4 KiB
TypeScript
176 lines
6.4 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.
|
|
*/
|
|
export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
|
|
|
|
/** 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);
|
|
|
|
/** 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));
|
|
}
|
|
}
|