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

+7
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "naive_engine"
version = "0.1.0"
edition = "2021"
license = "MIT"
[dependencies]
+16
View File
@@ -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/`.
+116
View File
@@ -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}"),
}
}
+36
View File
@@ -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",
]
);
}