feat: module 10 spellgrid
SpellGrid capstone: Part I's card engine and Part II's grid engine joined at three seams (rules -> aura data, hazard victims -> one destroy Sequence, Sequence end -> write text, remove the dead, reparse). Three levels with scripted solutions, a naive-timing test via a harness-only hook that reverses the END_OF_PHASE list, CLIs in both tracks, the BATTLECRY IS DOUBLE Expansion Drop, SpellGrid cards and words in course/cards and the catalog, level schema cards/hand, and tools staging/copying of module levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f3359cfe2
commit
a0cf9be214
96 files changed
+10454
-8
No files matched your search
@@ -0,0 +1,134 @@
|
||||
//! Movement: validate, then commit.
|
||||
//!
|
||||
//! A move is planned before anything on the board changes. The `Planner` asks
|
||||
//! whether one object can step one tile; to answer, it runs every movement
|
||||
//! response content provides for that step, and a response may ask the planner
|
||||
//! to move other objects too (a push), or to destroy objects. If any response
|
||||
//! says no, the planner forgets everything that step added to the plan, so a
|
||||
//! failed push leaves no trace. Only a plan that succeeded is committed.
|
||||
//!
|
||||
//! The engine knows the board, its edge, and the order of the steps. What each
|
||||
//! property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
|
||||
//! response in content, run in the order content lists them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::grid::ObjectId;
|
||||
use super::level::Level;
|
||||
|
||||
/// One of the four directions a move can take.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dir {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
/// One tile in this direction, as `(dx, dy)`; `y` grows down.
|
||||
pub fn delta(self) -> (i32, i32) {
|
||||
match self {
|
||||
Dir::Up => (0, -1),
|
||||
Dir::Down => (0, 1),
|
||||
Dir::Left => (-1, 0),
|
||||
Dir::Right => (1, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One object stepping one tile, from `from` to `to`, in direction `dir`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Step {
|
||||
pub mover: ObjectId,
|
||||
pub from: (i32, i32),
|
||||
pub to: (i32, i32),
|
||||
pub dir: Dir,
|
||||
}
|
||||
|
||||
/// A movement response: content's rule for what happens when an object steps.
|
||||
/// It returns `false` to block the step. It may plan other moves or destroy
|
||||
/// objects through the planner.
|
||||
pub type Response = fn(&mut Planner<'_>, Step) -> bool;
|
||||
|
||||
/// What a move will do: the objects that step one tile, and the objects destroyed.
|
||||
/// A destroyed object doesn't step, even if it was pushed.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub moving: BTreeSet<ObjectId>,
|
||||
pub destroyed: BTreeSet<ObjectId>,
|
||||
}
|
||||
|
||||
/// Plans one move on a board it only reads.
|
||||
pub struct Planner<'a> {
|
||||
level: &'a Level,
|
||||
dir: Dir,
|
||||
plan: Plan,
|
||||
}
|
||||
|
||||
impl<'a> Planner<'a> {
|
||||
pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> {
|
||||
Planner {
|
||||
level,
|
||||
dir,
|
||||
plan: Plan::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does object `id` have `property`, under the rules in force before the move?
|
||||
pub fn has(&self, id: ObjectId, property: &str) -> bool {
|
||||
self.level.has_property(id, property)
|
||||
}
|
||||
|
||||
/// The objects that will still be on tile `at` when the plan so far is
|
||||
/// committed: those there now, less those planned to move away or be destroyed.
|
||||
/// Oldest first.
|
||||
pub fn staying_at(&self, (x, y): (i32, i32)) -> Vec<ObjectId> {
|
||||
self.level
|
||||
.board()
|
||||
.at(x, y)
|
||||
.map(|o| o.id)
|
||||
.filter(|id| !self.plan.moving.contains(id) && !self.plan.destroyed.contains(id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Plan object `id`'s destruction.
|
||||
pub fn destroy(&mut self, id: ObjectId) {
|
||||
self.plan.destroyed.insert(id);
|
||||
}
|
||||
|
||||
/// Plan object `id` stepping one tile in the move's direction, with whatever
|
||||
/// the responses add. Returns whether it can. If it can't, the plan is left as
|
||||
/// it was before the call. An object already planned to move, or to be
|
||||
/// destroyed, is out of the way: the answer is yes, and nothing is added.
|
||||
pub fn try_move(&mut self, id: ObjectId) -> bool {
|
||||
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
let level = self.level;
|
||||
let o = level.board().get(id);
|
||||
let (dx, dy) = self.dir.delta();
|
||||
let to = (o.x + dx, o.y + dy);
|
||||
// The edge of the level blocks every step (the course's choice; see the lesson).
|
||||
if !level.board().in_bounds(to.0, to.1) {
|
||||
return false;
|
||||
}
|
||||
let before = self.plan.clone();
|
||||
self.plan.moving.insert(id);
|
||||
let step = Step {
|
||||
mover: id,
|
||||
from: (o.x, o.y),
|
||||
to,
|
||||
dir: self.dir,
|
||||
};
|
||||
let allowed = level.responses().iter().all(|respond| respond(self, step));
|
||||
if !allowed {
|
||||
self.plan = before;
|
||||
}
|
||||
allowed
|
||||
}
|
||||
|
||||
pub fn into_plan(self) -> Plan {
|
||||
self.plan
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user