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:
1 parent
00662639ba
commit
ffa99785c3
13 files changed
+2121
No files matched your search
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "naive_engine"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "naive_engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Naive engine: Rust
|
||||
|
||||
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 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 (`make_minion` and `cast` in `src/lib.rs`).
|
||||
|
||||
Run the tests from this directory:
|
||||
|
||||
```
|
||||
cargo test
|
||||
```
|
||||
|
||||
You should see two passing tests:
|
||||
|
||||
- `test_00_naive_runs` passes normally.
|
||||
- `test_00_real_rules_whirlwind_order - should panic ... ok` is expected to fail. It states the real game's event order, which this engine cannot produce, so it is marked `#[should_panic]`. To see the order the engine actually produces, delete the `#[should_panic(...)]` line and run `cargo test` again.
|
||||
|
||||
Do not use this crate in Module 1. Your real engine starts from `workspace-template/rust/`.
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A minion is shared by reference: the board and anyone holding it see the same one.
|
||||
pub type MinionRef = Rc<RefCell<Minion>>;
|
||||
|
||||
/// What a card does, written as a plain function the engine calls directly.
|
||||
pub type Effect = fn(&mut Game, &MinionRef);
|
||||
|
||||
pub struct Minion {
|
||||
pub name: String,
|
||||
pub attack: i32,
|
||||
pub health: i32,
|
||||
/// Called the moment this minion takes damage.
|
||||
pub on_damaged: Option<Effect>,
|
||||
/// Called the moment this minion dies.
|
||||
pub deathrattle: Option<Effect>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Game {
|
||||
/// The minions on the board, left to right. There is only one player.
|
||||
pub board: Vec<MinionRef>,
|
||||
pub cards_drawn: u32,
|
||||
/// What happened, in order, as plain text.
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Puts a minion on the right end of the board and returns it.
|
||||
pub fn summon(&mut self, card: &str) -> MinionRef {
|
||||
let minion = Rc::new(RefCell::new(make_minion(card)));
|
||||
self.board.push(Rc::clone(&minion));
|
||||
minion
|
||||
}
|
||||
|
||||
pub fn cast(&mut self, card: &str) {
|
||||
match card {
|
||||
// Deal 1 damage to ALL minions.
|
||||
"Whirlwind" => {
|
||||
for minion in self.board.clone() {
|
||||
self.deal_damage(&minion, 1);
|
||||
}
|
||||
}
|
||||
other => panic!("unknown spell: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Damage happens now. The damage trigger fires now. Death happens now.
|
||||
pub fn deal_damage(&mut self, target: &MinionRef, amount: i32) {
|
||||
let (name, on_damaged) = {
|
||||
let mut minion = target.borrow_mut();
|
||||
minion.health -= amount;
|
||||
(minion.name.clone(), minion.on_damaged)
|
||||
};
|
||||
self.log.push(format!("damaged {name} {amount}"));
|
||||
if let Some(effect) = on_damaged {
|
||||
self.log.push(format!("triggered {name}"));
|
||||
effect(self, target);
|
||||
}
|
||||
if target.borrow().health <= 0 {
|
||||
self.kill(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the minion and runs its Deathrattle straight away.
|
||||
fn kill(&mut self, target: &MinionRef) {
|
||||
self.board.retain(|minion| !Rc::ptr_eq(minion, target));
|
||||
let (name, deathrattle) = {
|
||||
let minion = target.borrow();
|
||||
(minion.name.clone(), minion.deathrattle)
|
||||
};
|
||||
self.log.push(format!("died {name}"));
|
||||
if let Some(effect) = deathrattle {
|
||||
self.log.push(format!("deathrattle {name}"));
|
||||
effect(self, target);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self) {
|
||||
self.cards_drawn += 1;
|
||||
self.log.push("drew a card".to_string());
|
||||
}
|
||||
|
||||
pub fn board_names(&self) -> Vec<String> {
|
||||
self.board.iter().map(|minion| minion.borrow().name.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every minion the game knows. A new card means a new branch here.
|
||||
fn make_minion(card: &str) -> Minion {
|
||||
let stats = |attack, health| Minion {
|
||||
name: card.to_string(),
|
||||
attack,
|
||||
health,
|
||||
on_damaged: None,
|
||||
deathrattle: None,
|
||||
};
|
||||
match card {
|
||||
// Whenever this minion takes damage, draw a card.
|
||||
"Acolyte of Pain" => Minion { on_damaged: Some(|game, _| game.draw()), ..stats(1, 4) },
|
||||
// Deathrattle: Draw a card.
|
||||
"Loot Hoarder" => Minion { deathrattle: Some(|game, _| game.draw()), ..stats(2, 1) },
|
||||
other => panic!("unknown minion: {other}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Module 0 tests for the naive engine. See course/00-rewrite-trap/lesson.md.
|
||||
use naive_engine::Game;
|
||||
|
||||
#[test]
|
||||
fn test_00_naive_runs() {
|
||||
let mut game = Game::new();
|
||||
let hoarder = game.summon("Loot Hoarder");
|
||||
game.deal_damage(&hoarder, 1);
|
||||
assert!(game.board_names().is_empty(), "Loot Hoarder should be gone");
|
||||
assert_eq!(game.cards_drawn, 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]
|
||||
#[should_panic(expected = "left == right")]
|
||||
fn test_00_real_rules_whirlwind_order() {
|
||||
let mut game = Game::new();
|
||||
game.summon("Acolyte of Pain"); // played first
|
||||
game.summon("Loot Hoarder");
|
||||
game.cast("Whirlwind");
|
||||
assert_eq!(
|
||||
game.log,
|
||||
vec![
|
||||
"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",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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/`.
|
||||
Generated
+1497
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["tests/**/*.test.ts"] },
|
||||
});
|
||||
Reference in new issue
Block a user