feat: module 0 rewrite trap

Naive engine in Rust and TypeScript (immediate damage, inline triggers,
recursive Deathrattles, cards defined in the engine) with
test_00_naive_runs and the expected-fail test_00_real_rules_whirlwind_order,
plus the Module 0 lesson.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 00:07:14 +01:00
1 parent 00662639ba
commit ffa99785c3
13 files changed
+2121

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Naive engine: TypeScript
The throwaway engine for [Module 0, The Rewrite Trap](../../course/00-rewrite-trap/lesson.md). It is written the obvious way on purpose: minions are shared object references, damage applies at once, triggers fire inline, and a Deathrattle runs the instant a minion's Health reaches 0. Cards are defined inside the engine (`makeMinion` and `cast` in `src/engine.ts`).
Run the tests from this directory:
```
npm install
npx vitest run
```
You should see two passing tests:
- `test_00_naive_runs` passes normally.
- `test_00_real_rules_whirlwind_order` is expected to fail. It states the real game's event order, which this engine cannot produce, so it is marked `test.fails`. To see the order the engine actually produces, change `test.fails(` to `test(` and run the tests again.
`npm run typecheck` runs the TypeScript compiler.
Do not use this package in Module 1. Your real engine starts from `workspace-template/ts/`.
+1497
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "naive-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+93
View File
@@ -0,0 +1,93 @@
// The naive engine for Module 0, The Rewrite Trap.
//
// It is written the "obvious" way on purpose, and it is wrong in ways worth
// studying. Read course/00-rewrite-trap/lesson.md before you change it.
// You throw this engine away at the end of Module 0.
/** What a card does, written as a plain function the engine calls directly. */
export type Effect = (game: Game, self: Minion) => void;
/** A minion is shared by reference: the board and anyone holding it see the same object. */
export interface Minion {
name: string;
attack: number;
health: number;
/** Called the moment this minion takes damage. */
onDamaged?: Effect;
/** Called the moment this minion dies. */
deathrattle?: Effect;
}
export class Game {
/** The minions on the board, left to right. There is only one player. */
board: Minion[] = [];
cardsDrawn = 0;
/** What happened, in order, as plain text. */
log: string[] = [];
/** Puts a minion on the right end of the board and returns it. */
summon(card: string): Minion {
const minion = makeMinion(card);
this.board.push(minion);
return minion;
}
cast(card: string): void {
switch (card) {
// Deal 1 damage to ALL minions.
case "Whirlwind":
for (const minion of [...this.board]) {
this.dealDamage(minion, 1);
}
break;
default:
throw new Error(`unknown spell: ${card}`);
}
}
/** Damage happens now. The damage trigger fires now. Death happens now. */
dealDamage(target: Minion, amount: number): void {
target.health -= amount;
this.log.push(`damaged ${target.name} ${amount}`);
if (target.onDamaged) {
this.log.push(`triggered ${target.name}`);
target.onDamaged(this, target);
}
if (target.health <= 0) {
this.kill(target);
}
}
/** Removes the minion and runs its Deathrattle straight away. */
private kill(target: Minion): void {
this.board = this.board.filter((minion) => minion !== target);
this.log.push(`died ${target.name}`);
if (target.deathrattle) {
this.log.push(`deathrattle ${target.name}`);
target.deathrattle(this, target);
}
}
draw(): void {
this.cardsDrawn += 1;
this.log.push("drew a card");
}
boardNames(): string[] {
return this.board.map((minion) => minion.name);
}
}
/** Every minion the game knows. A new card means a new case here. */
function makeMinion(card: string): Minion {
switch (card) {
// Whenever this minion takes damage, draw a card.
case "Acolyte of Pain":
return { name: card, attack: 1, health: 4, onDamaged: (game) => game.draw() };
// Deathrattle: Draw a card.
case "Loot Hoarder":
return { name: card, attack: 2, health: 1, deathrattle: (game) => game.draw() };
default:
throw new Error(`unknown minion: ${card}`);
}
}
+31
View File
@@ -0,0 +1,31 @@
// Module 0 tests for the naive engine. See course/00-rewrite-trap/lesson.md.
import { expect, test } from "vitest";
import { Game } from "../src/engine";
test("test_00_naive_runs", () => {
const game = new Game();
const hoarder = game.summon("Loot Hoarder");
game.dealDamage(hoarder, 1);
expect(game.boardNames()).toEqual([]); // Loot Hoarder is gone
expect(game.cardsDrawn).toBe(1); // its Deathrattle draws one card
});
// EXPECTED TO FAIL. This test states the order the real game uses (all damage
// first, then damage triggers in order of play, then deaths and Deathrattles).
// The naive engine cannot produce it. Read course/00-rewrite-trap/lesson.md,
// "Predict", before changing anything here.
test.fails("test_00_real_rules_whirlwind_order", () => {
const game = new Game();
game.summon("Acolyte of Pain"); // played first
game.summon("Loot Hoarder");
game.cast("Whirlwind");
expect(game.log).toEqual([
"damaged Acolyte of Pain 1",
"damaged Loot Hoarder 1",
"triggered Acolyte of Pain",
"drew a card",
"died Loot Hoarder",
"deathrattle Loot Hoarder",
"drew a card",
]);
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src", "tests"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["tests/**/*.test.ts"] },
});