Bug 1 (modules 08-10): a YOU object pulled by an earlier mover took a second step of its own. Level movement now keeps the ids that stepped in a committed plan this phase; such a YOU object starts no move, and Planner try_move answers no for it. Regression: test_08x_pulled_you_moves_once (Module 8 Expansion Drop). Bug 2 (modules 09-10): convert honoured negation only per rule, so ALL IS ROCK beat BABA IS NOT ROCK. A target Y is now skipped for an object if any active negated rule IS NOT Y names it. Module 9 and 10 references (and their drop solutions) now carry ALL, the Module 7 Expansion Drop. Regression: test_09_negated_conversion_beats_group. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
144 lines
4.8 KiB
Rust
144 lines
4.8 KiB
Rust
//! 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. `stepped` holds the objects that have
|
|
/// already stepped in this movement phase, in an earlier committed plan: an object
|
|
/// steps at most once per phase, so none of them can step again.
|
|
pub struct Planner<'a> {
|
|
level: &'a Level,
|
|
dir: Dir,
|
|
stepped: &'a BTreeSet<ObjectId>,
|
|
plan: Plan,
|
|
}
|
|
|
|
impl<'a> Planner<'a> {
|
|
pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet<ObjectId>) -> Planner<'a> {
|
|
Planner {
|
|
level,
|
|
dir,
|
|
stepped,
|
|
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. An
|
|
/// object that has already stepped in this movement phase can't step again: the
|
|
/// answer is no.
|
|
pub fn try_move(&mut self, id: ObjectId) -> bool {
|
|
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
|
|
return true;
|
|
}
|
|
if self.stepped.contains(&id) {
|
|
return false;
|
|
}
|
|
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
|
|
}
|
|
}
|