fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- SpellGrid cards move to course/10-spellgrid/cards; start-module and verify-references stage course/cards plus module-local cards up to the module, so M5-M9 workspaces get exactly the pre-M10 card set. - cast() rejects a chosen-target spell without a target (checkCast/checkPlay); new test_10_targeted_spell_needs_target. - Rust CLI pre-validates instead of swapping the global panic hook. - Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role. - Lesson: design note on mid-movement reparse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a0cf9be214
commit
6f576940b7
@@ -2,7 +2,7 @@
|
||||
|
||||
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
|
||||
- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
|
||||
- `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- Part I (`src/engine/`, outside `puzzle/`):
|
||||
- `queue.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event.
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid};
|
||||
@@ -138,13 +137,7 @@ pub fn run(level_text: &str, input: Box<dyn BufRead>, output: Box<dyn Write>) ->
|
||||
.and_then(|n| options.get(n.wrapping_sub(1)));
|
||||
picked.unwrap_or(&options[0]).clone()
|
||||
});
|
||||
// The harness panics on a move the rules don't allow (a card not in the hand, a
|
||||
// full tile). The CLI reports the message instead of stopping.
|
||||
let hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(|_| {}));
|
||||
let outcome = play(&mut g, &level, &io);
|
||||
panic::set_hook(hook);
|
||||
outcome
|
||||
play(&mut g, &level, &io)
|
||||
}
|
||||
|
||||
/// Draw, read a command, do it; until the level is won or lost, or the player quits.
|
||||
@@ -172,27 +165,26 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc<RefCell<Io>>) -> Outcome {
|
||||
Some(&"d") => Some(Dir::Right),
|
||||
_ => None,
|
||||
};
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(|| match (dir, words.as_slice()) {
|
||||
(Some(dir), _) => g.step(dir),
|
||||
// The harness panics on a move the rules don't allow (a card not in the hand, a
|
||||
// full tile). The CLI asks first, and reports the reason instead.
|
||||
let done = match (dir, words.as_slice()) {
|
||||
(Some(dir), _) => Ok(g.step(dir)),
|
||||
(None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) {
|
||||
(card, None) => g.cast(&card, None),
|
||||
(card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)),
|
||||
(card, Some((x, y))) => match g.minion_at(x, y) {
|
||||
Some(target) => g.cast(&card, Some(target)),
|
||||
None => {
|
||||
Some(target) => g
|
||||
.check_cast(&card, Some(target))
|
||||
.map(|()| g.cast(&card, Some(target))),
|
||||
None => g.check_play(&card, x, y).map(|()| {
|
||||
g.play(&card, x, y);
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
_ => io
|
||||
.borrow_mut()
|
||||
.write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n"),
|
||||
}));
|
||||
if let Err(payload) = result {
|
||||
let message = payload
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "that can't be done".to_string());
|
||||
_ => {
|
||||
Err("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)".to_string())
|
||||
}
|
||||
};
|
||||
if let Err(message) = done {
|
||||
io.borrow_mut().write(&format!("{message}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "name": "MOTH", "role": "noun" },
|
||||
{ "name": "SCRIBE", "role": "noun" },
|
||||
{ "name": "KNIGHT", "role": "noun" },
|
||||
{ "name": "CRIER", "role": "noun" },
|
||||
{ "name": "DEATHRATTLE", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::aura::aura_update_other;
|
||||
use super::card::{Aura, CardLibrary, CardType, Stacking, TriggerOn};
|
||||
use super::card::{Aura, CardLibrary, CardType, Effect, Stacking, Target, TriggerOn};
|
||||
use super::event::Event;
|
||||
use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef};
|
||||
use super::queue::{run_sequence, Action};
|
||||
@@ -180,9 +180,62 @@ impl SpellGrid {
|
||||
self.side.grid_changed(&mut self.level);
|
||||
}
|
||||
|
||||
/// Why `cast(card, target)` can't be done now, or `Ok` if it can: the card must be a
|
||||
/// spell in player 0's hand, and a spell that hits a chosen minion needs a target
|
||||
/// minion on the grid. Nothing changes either way.
|
||||
pub fn check_cast(&self, card: &str, target: Option<ObjectId>) -> Result<(), String> {
|
||||
self.side.check_hand(card, CardType::Spell)?;
|
||||
let chooses = self.side.world.card(card).on_cast.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::DealDamage {
|
||||
target: Target::Chosen,
|
||||
..
|
||||
} | Effect::GainAttack {
|
||||
target: Target::Chosen,
|
||||
..
|
||||
} | Effect::Destroy {
|
||||
target: Target::Chosen
|
||||
}
|
||||
)
|
||||
});
|
||||
match target {
|
||||
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.
|
||||
let card = self.side.entities.contains_key(&id);
|
||||
if card && self.level.board().find(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{id} is not a minion on the grid"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `play(card, x, y)` can't be done now, or `Ok` if it can.
|
||||
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"));
|
||||
}
|
||||
if !self.level.board().in_bounds(x, y) {
|
||||
return Err(format!("({x}, {y}) is off the grid"));
|
||||
}
|
||||
if self.level.board().at(x, y).next().is_some() {
|
||||
return Err(format!("({x}, {y}) is not empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
|
||||
/// Panics if `check_cast` says no.
|
||||
pub fn cast(&mut self, card: &str, target: Option<ObjectId>) {
|
||||
let in_hand = self.side.from_hand(card, CardType::Spell);
|
||||
if let Err(problem) = self.check_cast(card, target) {
|
||||
panic!("{problem}");
|
||||
}
|
||||
let in_hand = self.side.in_hand(card).expect("checked");
|
||||
self.side.world.move_to(in_hand, Zone::Graveyard);
|
||||
let target = target.map(|id| self.side.entity(id));
|
||||
let action = Action::Cast {
|
||||
@@ -194,13 +247,12 @@ impl SpellGrid {
|
||||
}
|
||||
|
||||
/// Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence.
|
||||
/// Returns its new id.
|
||||
/// Returns its new id. Panics if `check_play` says no.
|
||||
pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId {
|
||||
let in_hand = self.side.from_hand(card, CardType::Minion);
|
||||
assert!(
|
||||
self.level.board().at(x, y).next().is_none(),
|
||||
"({x}, {y}) is not empty"
|
||||
);
|
||||
if let Err(problem) = self.check_play(card, x, y) {
|
||||
panic!("{problem}");
|
||||
}
|
||||
let in_hand = self.side.in_hand(card).expect("checked");
|
||||
let kind = self.side.noun_of(card);
|
||||
let id = self.level.board_mut().place(kind, x, y);
|
||||
self.side.link(in_hand, id);
|
||||
@@ -241,19 +293,26 @@ impl CardSide {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_hand(&self, card: &str, card_type: CardType) -> EntityId {
|
||||
let in_hand = self
|
||||
.world
|
||||
fn in_hand(&self, card: &str) -> Option<EntityId> {
|
||||
self.world
|
||||
.zone(0, Zone::Hand)
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&id| self.world.card_name(id) == card)
|
||||
.unwrap_or_else(|| panic!("{card:?} is not in the hand"));
|
||||
assert!(
|
||||
self.world.card(card).def.card_type == card_type,
|
||||
"{card:?} is not a {card_type:?}"
|
||||
);
|
||||
in_hand
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
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}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seam 2. Objects that aren't cards leave the board. Minions are marked pending
|
||||
|
||||
@@ -436,6 +436,14 @@ impl SpellGrid {
|
||||
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
|
||||
self.grid.world_mut().set_chooser(Box::new(chooser));
|
||||
}
|
||||
/// For the command line: why `cast` would panic, or `Ok` if it wouldn't.
|
||||
pub fn check_cast(&self, card: &str, target: Option<Id>) -> Result<(), String> {
|
||||
self.grid.check_cast(card, target)
|
||||
}
|
||||
/// For the command line: why `play` would panic, or `Ok` if it wouldn't.
|
||||
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
|
||||
self.grid.check_play(card, x, y)
|
||||
}
|
||||
/// Test hook: the naive engine reads the auras before the dead leave play, the
|
||||
/// same end-of-Phase steps reversed.
|
||||
pub fn with_naive_aura_timing(&mut self) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
|
||||
- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
|
||||
- `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the three seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- Part I (`src/engine/`, outside `puzzle/`):
|
||||
- `queue.ts`: `END_OF_PHASE` (`["deathCreation", "auraUpdate"]`) is the list of steps at the end of the outermost Phase, read from the world. The `spawnText` action records a `TextSpawned` event.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "name": "MOTH", "role": "noun" },
|
||||
{ "name": "SCRIBE", "role": "noun" },
|
||||
{ "name": "KNIGHT", "role": "noun" },
|
||||
{ "name": "CRIER", "role": "noun" },
|
||||
{ "name": "DEATHRATTLE", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
|
||||
@@ -150,29 +150,62 @@ export class SpellGrid {
|
||||
this.gridChanged();
|
||||
}
|
||||
|
||||
/** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. */
|
||||
/**
|
||||
* Why `cast(card, target)` can't be done now, or `undefined` if it can: the card must
|
||||
* be a spell in player 0's hand, and a spell that hits a chosen minion needs a target
|
||||
* minion on the grid. Nothing changes when the answer is no.
|
||||
*/
|
||||
checkCast(card: string, target?: puzzle.ObjectId): string | undefined {
|
||||
const problem = this.checkHand(card, "spell");
|
||||
if (problem !== undefined) return problem;
|
||||
const chooses = this.world.card(card).onCast.some((e) => "target" in e && e.target === "chosen");
|
||||
if (target === undefined) return chooses ? `"${card}" needs a target minion` : undefined;
|
||||
const object = this.level.board.find(target);
|
||||
if (object === undefined || !this.entities.has(target)) return `${target} is not a minion on the grid`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Why `play(card, x, y)` can't be done now, or `undefined` if it can. */
|
||||
checkPlay(card: string, x: number, y: number): string | undefined {
|
||||
const problem = this.checkHand(card, "minion");
|
||||
if (problem !== undefined) return problem;
|
||||
if (this.world.card(card).def.noun === undefined) return `"${card}" has no noun: it can't stand on the grid`;
|
||||
if (!this.level.board.inBounds(x, y)) return `(${x}, ${y}) is off the grid`;
|
||||
if (this.level.board.at(x, y).length > 0) return `(${x}, ${y}) is not empty`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. Throws if `checkCast` says no. */
|
||||
cast(card: string, target?: puzzle.ObjectId): void {
|
||||
const inHand = this.fromHand(card, "spell");
|
||||
this.world.moveTo(inHand, "graveyard");
|
||||
const problem = this.checkCast(card, target);
|
||||
if (problem !== undefined) throw new Error(problem);
|
||||
this.world.moveTo(this.inHand(card)!, "graveyard");
|
||||
const chosen = target === undefined ? undefined : this.entity(target);
|
||||
this.sequence({ type: "cast", card, player: 0, target: chosen });
|
||||
}
|
||||
|
||||
/** Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence. Returns its new id. */
|
||||
/**
|
||||
* Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence.
|
||||
* Returns its new id. Throws if `checkPlay` says no.
|
||||
*/
|
||||
play(card: string, x: number, y: number): puzzle.ObjectId {
|
||||
const inHand = this.fromHand(card, "minion");
|
||||
if (this.level.board.at(x, y).length > 0) throw new Error(`(${x}, ${y}) is not empty`);
|
||||
const problem = this.checkPlay(card, x, y);
|
||||
if (problem !== undefined) throw new Error(problem);
|
||||
const inHand = this.inHand(card)!;
|
||||
const id = this.level.board.place(this.nounOf(card), x, y);
|
||||
this.link(inHand, id);
|
||||
this.sequence({ type: "play", id: inHand });
|
||||
return id;
|
||||
}
|
||||
|
||||
private fromHand(card: string, type: "minion" | "spell"): EntityId {
|
||||
const inHand = this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card);
|
||||
if (inHand === undefined) throw new Error(`"${card}" is not in the hand`);
|
||||
if (this.world.card(card).def.type !== type) throw new Error(`"${card}" is not a ${type}`);
|
||||
return inHand;
|
||||
private inHand(card: string): EntityId | undefined {
|
||||
return this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card);
|
||||
}
|
||||
|
||||
private checkHand(card: string, type: "minion" | "spell"): string | undefined {
|
||||
if (this.inHand(card) === undefined) return `"${card}" is not in the hand`;
|
||||
if (this.world.card(card).def.type !== type) return `"${card}" is not a ${type}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- The seams ----
|
||||
|
||||
@@ -187,6 +187,9 @@ export interface SpellGrid extends Baba {
|
||||
clearLog(): void;
|
||||
setChooser(choose: Chooser): void; // makes Assemble's choices
|
||||
withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step
|
||||
// For the command line: why `cast`/`play` would fail, or null if they wouldn't
|
||||
checkCast(card: string, target?: Id): string | null;
|
||||
checkPlay(card: string, x: number, y: number): string | null;
|
||||
}
|
||||
|
||||
/** A SpellGrid level; `extra` cards are added to the course's cards, replacing any with the same name. */
|
||||
@@ -246,5 +249,7 @@ export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGri
|
||||
setChooser: (choose) => g.world.setChooser(choose),
|
||||
// The naive engine reads the auras before the dead leave play: the same steps, reversed.
|
||||
withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()),
|
||||
checkCast: (card, target) => g.checkCast(card, target) ?? null,
|
||||
checkPlay: (card, x, y) => g.checkPlay(card, x, y) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user