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>
146 lines
4.3 KiB
TypeScript
146 lines
4.3 KiB
TypeScript
// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
|
|
// objects may share a tile.
|
|
|
|
export type ObjectId = number;
|
|
|
|
/** A level in the course's level format (`course/schema/level.schema.json`). */
|
|
export interface LevelDef {
|
|
width: number;
|
|
height: number;
|
|
objects: LevelObject[];
|
|
/** Module 10, read only by SpellGrid: minions in play, each on its tile. */
|
|
cards?: LevelCard[];
|
|
/** Module 10, read only by SpellGrid: the cards in player 0's hand. */
|
|
hand?: string[];
|
|
}
|
|
|
|
/** A minion card on the grid (Module 10). */
|
|
export interface LevelCard {
|
|
card: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface LevelObject {
|
|
kind: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/**
|
|
* What an object is: a thing named by a noun (kind `"<noun>"`, the noun in lower case),
|
|
* or a piece of text carrying one word (kind `"text:<WORD>"`).
|
|
*/
|
|
export type Kind = { type: "thing"; noun: string } | { type: "text"; word: string };
|
|
|
|
const TEXT_PREFIX = "text:";
|
|
|
|
export const Kind = {
|
|
parse(kind: string): Kind {
|
|
return kind.startsWith(TEXT_PREFIX)
|
|
? { type: "text", word: kind.slice(TEXT_PREFIX.length).toUpperCase() }
|
|
: { type: "thing", noun: kind.toUpperCase() };
|
|
},
|
|
/** The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`. */
|
|
format(kind: Kind): string {
|
|
return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase();
|
|
},
|
|
};
|
|
|
|
export interface Obj {
|
|
id: ObjectId;
|
|
kind: Kind;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/** What `Board.restore` puts back (Module 9, undo): every object, oldest first, and the id counter. */
|
|
export interface BoardState {
|
|
objects: Obj[];
|
|
nextId: ObjectId;
|
|
}
|
|
|
|
export class Board {
|
|
private readonly objectsById = new Map<ObjectId, Obj>();
|
|
private nextId = 0;
|
|
|
|
constructor(
|
|
readonly width: number,
|
|
readonly height: number,
|
|
) {}
|
|
|
|
inBounds(x: number, y: number): boolean {
|
|
return x >= 0 && x < this.width && y >= 0 && y < this.height;
|
|
}
|
|
|
|
/** Add an object; ids start at 1 and are never reused. Throws off the board. */
|
|
place(kind: Kind, x: number, y: number): ObjectId {
|
|
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
|
|
const id = ++this.nextId;
|
|
this.objectsById.set(id, { id, kind, x, y });
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* An id that no object will ever have (Module 10): SpellGrid gives one to each
|
|
* card that isn't on the grid, so cards and objects share one id space.
|
|
*/
|
|
reserveId(): ObjectId {
|
|
return ++this.nextId;
|
|
}
|
|
|
|
/** The object `id`. Throws if there is none. */
|
|
get(id: ObjectId): Obj {
|
|
const o = this.objectsById.get(id);
|
|
if (o === undefined) throw new Error(`no object ${id}`);
|
|
return o;
|
|
}
|
|
|
|
/** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */
|
|
find(id: ObjectId): Obj | undefined {
|
|
if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`);
|
|
return this.objectsById.get(id);
|
|
}
|
|
|
|
/** Take object `id` off the board for good. */
|
|
remove(id: ObjectId): void {
|
|
this.objectsById.delete(id);
|
|
}
|
|
|
|
/** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */
|
|
shift(id: ObjectId, [dx, dy]: readonly [number, number]): void {
|
|
const o = this.get(id);
|
|
const [x, y] = [o.x + dx, o.y + dy];
|
|
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
|
|
o.x = x;
|
|
o.y = y;
|
|
}
|
|
|
|
/** Make object `id` a `kind`, where it stands. Throws if there is no such object. */
|
|
setKind(id: ObjectId, kind: Kind): void {
|
|
this.get(id).kind = kind;
|
|
}
|
|
|
|
/** The objects on tile (x, y), oldest first. */
|
|
at(x: number, y: number): Obj[] {
|
|
return this.objects().filter((o) => o.x === x && o.y === y);
|
|
}
|
|
|
|
/** Every object, oldest first. */
|
|
objects(): Obj[] {
|
|
return [...this.objectsById.values()];
|
|
}
|
|
|
|
/** A copy of everything the board holds (Module 9, undo). */
|
|
snapshot(): BoardState {
|
|
return { objects: this.objects().map((o) => ({ ...o })), nextId: this.nextId };
|
|
}
|
|
|
|
/** Put the board back as `state` recorded it: the same objects, ids, ages and id counter. */
|
|
restore(state: BoardState): void {
|
|
this.objectsById.clear();
|
|
for (const o of state.objects) this.objectsById.set(o.id, { ...o });
|
|
this.nextId = state.nextId;
|
|
}
|
|
}
|