feat: module 02 action queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 02:30:26 +01:00
co-authored by Claude Opus 5.5
parent 78dc09b303
commit 9c50591d3b
36 changed files with 3616 additions and 0 deletions
@@ -0,0 +1,30 @@
// Content: card definitions. Only this directory may name specific cards.
//
// Each effect is a small function that returns engine actions. Module 5
// replaces these functions with card data the engine interprets.
import { CardLibrary, minion, spell } from "../engine";
/** Every card this game knows. */
export function library(): CardLibrary {
return new CardLibrary([
// Fictional test minion with no text.
minion("Test Filler", 1, 1),
// "Whenever a friendly minion takes damage, gain 1 Armor."
minion("Armorsmith", 1, 4, [
{ on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] },
]),
// "Whenever this minion takes damage, draw a card."
minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]),
// Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero."
minion("Test Echo", 1, 5, [
{ on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] },
]),
// Fictional: "Whenever a hero takes damage, gain +1 Attack."
minion("Test Reactor", 1, 5, [
{ on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] },
]),
// "Deal 1 damage to ALL minions."
spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]),
]);
}
@@ -0,0 +1,66 @@
// Card definitions as the engine sees them. The definitions themselves live in `content`.
import type { Action, Ctx } from "./queue";
/**
* A card's behaviour: given where and for whom it runs, the actions to perform.
* Module 2 writes these as functions in `content`; Module 5 turns them into data.
*/
export type EffectFn = (ctx: Ctx) => Action[];
/** 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
/** 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";
}
}
export interface Trigger {
on: TriggerOn;
effect: EffectFn;
}
/** What the engine needs to know about a card. */
export interface CardDef {
name: string;
attack: number;
health: number;
/** Triggered abilities, in printed order. */
triggers: Trigger[];
/** A spell's effect when cast; absent for minions. */
onCast?: EffectFn;
}
export function minion(name: string, attack: number, health: number, triggers: Trigger[] = []): CardDef {
return { name, attack, health, triggers };
}
export function spell(name: string, onCast: EffectFn): CardDef {
return { name, attack: 0, health: 0, triggers: [], onCast };
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, CardDef>();
constructor(defs: CardDef[]) {
for (const def of defs) this.cards.set(def.name, def);
}
get(name: string): CardDef {
const def = this.cards.get(name);
if (!def) throw new Error(`unknown card "${name}": define it in src/content`);
return def;
}
}
@@ -0,0 +1,14 @@
// What happened, in order. The engine records an event each time it changes the game.
import type { TriggerOn } from "./card";
import type { EntityId, PlayerId } from "./world";
export type Event =
| { type: "Summoned"; id: EntityId }
| { type: "Damaged"; target: EntityId; amount: number }
| { type: "Triggered"; source: EntityId; on: TriggerOn }
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string };
@@ -0,0 +1,22 @@
// Engine core: generic rules machinery. Never name a specific card here.
//
// Module 1: every card in the game is an entity addressed by an id handle.
// The engine knows which zone each entity is in, who owns it, and when it
// entered play.
//
// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
// action queue that resolves a Sequence depth-first, recording an event log.
export {
CardLibrary,
minion,
spell,
triggerLabel,
type CardDef,
type EffectFn,
type Trigger,
type TriggerOn,
} from "./card";
export type { Event } from "./event";
export { Ctx, runSequence, type Action } from "./queue";
export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world";
@@ -0,0 +1,116 @@
// The action queue: how one player action (a Sequence) resolves.
//
// Performing an action changes the world and produces events. Every trigger that
// responds to those events goes into one Queue, oldest listener first, and the Queue
// is locked. Each queued trigger then resolves completely, including everything
// its own actions cause, before the next one starts: depth-first.
import type { Trigger, TriggerOn } from "./card";
import type { Event } from "./event";
import type { EntityId, PlayerId, World } from "./world";
/** Something the engine can do. Card effects return these instead of changing the world. */
export type Action =
/** Deal `amount` to each target at once: all damage first, then the reactions. */
| { type: "damage"; targets: EntityId[]; amount: number }
| { type: "draw"; player: PlayerId }
| { type: "gainArmor"; player: PlayerId; amount: number }
| { type: "gainAttack"; id: EntityId; amount: number }
| { type: "cast"; card: string; player: PlayerId; target?: EntityId };
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
readonly world: World,
/** The player the effect works for. */
readonly owner: PlayerId,
/** The minion whose trigger is resolving; undefined for a spell. */
readonly source?: EntityId,
/** The chosen target, if any. */
readonly target?: EntityId,
) {}
/** The minion whose trigger is resolving. */
minion(): EntityId {
if (this.source === undefined) throw new Error("only a triggered ability has a source");
return this.source;
}
enemyHero(): EntityId {
return this.world.hero(this.owner === 0 ? 1 : 0);
}
}
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
// Module 3: when the outermost Phase ends, deaths are processed here.
}
function perform(world: World, action: Action): void {
switch (action.type) {
case "damage": {
// Area damage: every Damage Event is created, in order of play, before any reaction.
const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
const events: Event[] = [];
for (const target of targets) {
world.damage(target, action.amount);
events.push({ type: "Damaged", target, amount: action.amount });
}
react(world, events);
break;
}
case "draw":
world.draw(action.player);
break;
case "gainArmor":
world.gainArmor(action.player, action.amount);
break;
case "gainAttack":
world.gainAttack(action.id, action.amount);
break;
case "cast": {
const effect = world.card(action.card).onCast;
if (!effect) throw new Error(`"${action.card}" is not a spell`);
world.record({ type: "SpellCast", card: action.card });
for (const next of effect(new Ctx(world, action.player, undefined, action.target))) perform(world, next);
break;
}
}
}
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger][] = [];
for (const event of events) {
for (const listener of world.minionsInPlay()) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger]);
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger] of queue) {
world.record({ type: "Triggered", source, on: trigger.on });
const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source));
// Depth-first: each action, and every reaction it causes, finishes before the next.
for (const action of actions) perform(world, action);
}
}
/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */
function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean {
if (event.type !== "Damaged") return false;
const isMinion = !world.isHero(event.target);
switch (on) {
case "selfDamaged":
return event.target === listener;
case "friendlyMinionDamaged":
return isMinion && world.ownerOf(event.target) === world.ownerOf(listener);
case "anyMinionDamaged":
return isMinion;
case "heroDamaged":
return !isMinion;
}
}
@@ -0,0 +1,189 @@
// Entities, zones, order of play, stats and the event log: the game's state.
// The rules for resolving actions live in `queue.ts`.
import type { CardDef, CardLibrary } from "./card";
import type { Event } from "./event";
/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */
export type EntityId = number;
export type PlayerId = 0 | 1;
export type Zone = "deck" | "hand" | "board" | "graveyard";
/** Starting Health of each hero. */
export const HERO_HEALTH = 30;
interface Entity {
card: string;
owner: PlayerId;
zone: Zone;
/** Stamped each time the entity enters the board; undefined until it first does. */
orderOfPlay?: number;
attack: number;
/** Current Health. It can drop to 0 or below: Module 2 has no death step. */
health: number;
}
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
type Side = Record<Zone, EntityId[]> & { hero: EntityId; armor: number };
export class World {
/** Indexed by EntityId. */
private readonly entities: Entity[] = [];
private readonly sides: [Side, Side];
private nextOrderOfPlay = 0;
private events: Event[] = [];
/** A new game: both heroes are in play from the start, so they are the oldest entities. */
constructor(private readonly library: CardLibrary) {
const side = (owner: PlayerId): Side => {
const hero = this.entities.length;
this.entities.push({
card: "Hero",
owner,
zone: "board",
orderOfPlay: ++this.nextOrderOfPlay,
attack: 0,
health: HERO_HEALTH,
});
// In play, but not on the board list: the board holds minions only.
return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 };
};
this.sides = [side(0), side(1)];
}
/** Create an entity of `card` for `owner` at the end of `zone` (the right end for the board). */
create(card: string, owner: PlayerId, zone: Zone): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
const def = this.library.get(card);
const id = this.entities.length;
this.entities.push({ card: def.name, owner, zone, attack: def.attack, health: def.health });
this.enter(id, zone);
return id;
}
/** Move an entity to the end of `zone` on its owner's side, keeping its id. */
moveTo(id: EntityId, zone: Zone): void {
const { owner, zone: from } = this.entity(id);
const list = this.sides[owner][from];
list.splice(list.indexOf(id), 1);
this.enter(id, zone);
}
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
private enter(id: EntityId, zone: Zone): void {
const entity = this.entity(id);
this.sides[entity.owner][zone].push(id);
entity.zone = zone;
if (zone === "board") {
entity.orderOfPlay = ++this.nextOrderOfPlay;
this.record({ type: "Summoned", id });
}
}
private entity(id: EntityId): Entity {
const entity = this.entities[id];
if (!entity) throw new Error(`unknown entity id ${id}`);
return entity;
}
// ---- Queries ----
card(name: string): CardDef {
return this.library.get(name);
}
zoneOf(id: EntityId): Zone {
return this.entity(id).zone;
}
cardName(id: EntityId): string {
return this.entity(id).card;
}
ownerOf(id: EntityId): PlayerId {
return this.entity(id).owner;
}
/** When the entity last entered the board (larger is later); undefined if it never has. */
orderOfPlay(id: EntityId): number | undefined {
return this.entity(id).orderOfPlay;
}
/** The ids in one player's zone, in zone order (left to right for the board). */
zone(owner: PlayerId, zone: Zone): readonly EntityId[] {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner][zone];
}
hero(owner: PlayerId): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner].hero;
}
isHero(id: EntityId): boolean {
return this.sides.some((s) => s.hero === id);
}
/** Every minion on either board, oldest first (order of play). */
minionsInPlay(): EntityId[] {
return [...this.sides[0].board, ...this.sides[1].board].sort(
(a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!,
);
}
attack(id: EntityId): number {
return this.entity(id).attack;
}
health(id: EntityId): number {
return this.entity(id).health;
}
armor(owner: PlayerId): number {
return this.sides[owner].armor;
}
log(): readonly Event[] {
return this.events;
}
// ---- Changes: each one records its event ----
record(event: Event): void {
this.events.push(event);
}
clearLog(): void {
this.events = [];
}
damage(target: EntityId, amount: number): void {
this.entity(target).health -= amount;
this.record({ type: "Damaged", target, amount });
}
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
*/
draw(player: PlayerId): void {
const id = this.zone(player, "deck")[0];
if (id === undefined) {
this.record({ type: "Fatigue", player });
return;
}
this.moveTo(id, "hand");
this.record({ type: "Drew", player, id });
}
gainArmor(player: PlayerId, amount: number): void {
this.sides[player].armor += amount;
this.record({ type: "ArmorGained", player, amount });
}
gainAttack(id: EntityId, amount: number): void {
const entity = this.entity(id);
entity.attack += amount;
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
}
}
@@ -0,0 +1,84 @@
// Test harness: the thin adapter the course tests call. It translates each
// call into engine operations and holds no rules of its own.
import { library } from "./content";
import { runSequence, triggerLabel, World, type Event, type Zone } from "./engine";
export type { Zone };
export type Id = number;
export type Player = 0 | 1;
/** The events the tests read from `log()` (see course/harness.md). */
export type GameEvent =
| { type: "Summoned"; id: Id }
| { type: "Damaged"; target: Id; amount: number }
| { type: "Triggered"; source: Id; trigger: string }
| { type: "Destroyed"; id: Id }
| { type: "DeathPhase"; round: number }
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
function toGameEvent(event: Event): GameEvent {
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
return { ...event };
}
export interface Game {
summon(card: string, player?: Player): Id; // to right end of board
addToHand(card: string, player?: Player): Id;
addToDeck(card: string, player?: Player): Id;
play(handId: Id): void; // Expansion Drop
zoneOf(id: Id): Zone;
board(player: Player): Id[]; // left to right
hand(player: Player): Id[];
orderOfPlay(id: Id): number;
nameOf(id: Id): string;
// Module 2
hero(player: Player): Id;
dealDamage(target: Id, amount: number): void; // one Sequence
cast(card: string, player?: Player, target?: Id): void;
health(id: Id): number;
attack(id: Id): number;
armor(player: Player): number;
log(): GameEvent[];
clearLog(): void;
}
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
const world = new World(library());
opts?.decks?.forEach((deck, player) => {
for (const card of deck) world.create(card, player as Player, "deck");
});
return {
summon: (card, player = 0) => world.create(card, player, "board"),
addToHand: (card, player = 0) => world.create(card, player, "hand"),
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
// Module 1 Expansion Drop: see course/01-handles/expansion/README.md.
play: () => {
throw new Error("not implemented: Module 1 Expansion Drop");
},
zoneOf: (id) => world.zoneOf(id),
board: (player) => [...world.zone(player, "board")],
hand: (player) => [...world.zone(player, "hand")],
orderOfPlay: (id) => {
const order = world.orderOfPlay(id);
if (order === undefined) throw new Error(`entity ${id} has not entered play`);
return order;
},
nameOf: (id) => world.cardName(id),
hero: (player) => world.hero(player),
dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }),
cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }),
health: (id) => world.health(id),
attack: (id) => world.attack(id),
armor: (player) => world.armor(player),
log: () => world.log().map(toGameEvent),
clearLog: () => world.clearLog(),
};
}