feat: Module 11 becomes 13; diff-tracks fuzzer; align the two reference tracks

- Rename course/11-break-your-engine to 13-break-your-engine and update every
  link and mention; start-module explains that Module 13 has no tests to start.
- tools/diff-tracks.mjs (npm run diff:tracks): stages both references of a
  module, generates a seeded random SpellGrid level and script per seed, pipes
  it to the Rust and TypeScript CLIs and diffs their output; crashes on one or
  both sides fail. stagePlay takes a module number.
- register_assembled: the TypeScript track compared only the text of an
  existing Assembled card, so a base re-registered with a new cost (or a
  different split) silently rewrote the earlier card. It now compares every
  field the engine reads, as Rust does (Modules 5-10).
  New test_05_assemble_never_rewrites_an_earlier_card.
- SpellGrid CLIs: coordinates and chooser numbers are 1-9 plain digits in both
  tracks (Rust accepted signs, TypeScript accepted "+2", "0x2", "1e0"); Rust
  quotes card names verbatim instead of with Debug escapes; Rust joins the
  minion list with "; " instead of rewriting ", ". Two new CLI tests per track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 10:08:56 +01:00
1 parent b36f4605cc
commit 06a1b34c5d
32 files changed
+645 -55

No files matched your search

+14 -12
View File
@@ -82,26 +82,29 @@ pub fn render(g: &SpellGrid, level: &LevelDef) -> String {
}
}
}
let or = |list: Vec<String>, none: &str| {
let or = |list: Vec<String>, separator: &str, none: &str| {
if list.is_empty() {
none.to_string()
} else {
list.join(", ")
list.join(separator)
}
};
lines.push(format!(
"Minions: {}",
or(minions, "none").replace(", ", "; ")
));
lines.push(format!("Hand: {}", or(g.hand(), "empty")));
lines.push(format!("Minions: {}", or(minions, "; ", "none")));
lines.push(format!("Hand: {}", or(g.hand(), ", ", "empty")));
lines.join("\n")
}
/// A coordinate, or a chooser's number: one to nine digits, nothing else (no sign, no point).
fn number(word: &str) -> Option<i32> {
let digits = (1..=9).contains(&word.len()) && word.bytes().all(|b| b.is_ascii_digit());
digits.then(|| word.parse().expect("nine digits fit an i32"))
}
/// A `c` command: `c <card words...> [x y]`.
fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) {
let n = args.len();
if n >= 3 {
if let (Ok(x), Ok(y)) = (args[n - 2].parse(), args[n - 1].parse()) {
if let (Some(x), Some(y)) = (number(args[n - 2]), number(args[n - 1])) {
return (args[..n - 2].join(" "), Some((x, y)));
}
}
@@ -131,10 +134,9 @@ pub fn run(level_text: &str, input: Box<dyn BufRead>, output: Box<dyn Write>) ->
if options.contains(&answer) {
return answer;
}
let picked = answer
.parse::<usize>()
.ok()
.and_then(|n| options.get(n.wrapping_sub(1)));
let picked = number(&answer)
.and_then(|n| usize::try_from(n - 1).ok())
.and_then(|i| options.get(i));
picked.unwrap_or(&options[0]).clone()
});
play(&mut g, &level, &io)
@@ -200,7 +200,7 @@ impl SpellGrid {
)
});
match target {
None if chooses => Err(format!("{card:?} needs a target minion")),
None if chooses => Err(format!("\"{card}\" needs a target minion")),
None => Ok(()),
Some(id) => {
// Every id in `entities` was issued by the board, so `find` may ask about it.
@@ -218,7 +218,9 @@ impl SpellGrid {
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
self.side.check_hand(card, CardType::Minion)?;
if self.side.world.card(card).def.noun.is_none() {
return Err(format!("{card:?} has no noun: it can't stand on the grid"));
return Err(format!(
"\"{card}\" has no noun: it can't stand on the grid"
));
}
if !self.level.board().in_bounds(x, y) {
return Err(format!("({x}, {y}) is off the grid"));
@@ -303,14 +305,14 @@ impl CardSide {
fn check_hand(&self, card: &str, card_type: CardType) -> Result<(), String> {
if self.in_hand(card).is_none() {
return Err(format!("{card:?} is not in the hand"));
return Err(format!("\"{card}\" is not in the hand"));
}
if self.world.card(card).def.card_type != card_type {
let kind = match card_type {
CardType::Minion => "minion",
CardType::Spell => "spell",
};
return Err(format!("{card:?} is not a {kind}"));
return Err(format!("\"{card}\" is not a {kind}"));
}
Ok(())
}