spinners and fmt
This commit is contained in:
+393
-2
@@ -5,7 +5,28 @@ use crate::log::LogLine;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Direction;
|
||||
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
|
||||
/// every source cell before writing any destination (so cyclic moves work).
|
||||
#[derive(Clone)]
|
||||
enum SolidSnapshot {
|
||||
/// The player occupied the cell.
|
||||
Player,
|
||||
/// A solid scripted object occupied the cell, identified by its stable id.
|
||||
Object(ObjectId),
|
||||
/// A solid terrain cell — its visual/behavior plus the layer it lived on.
|
||||
Terrain {
|
||||
/// Layer the terrain lived on (restored on the destination).
|
||||
z: usize,
|
||||
/// The cell's glyph.
|
||||
glyph: Glyph,
|
||||
/// The cell's archetype.
|
||||
arch: Archetype,
|
||||
},
|
||||
/// No solid occupant.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// The complete state of one game board (a single room or screen).
|
||||
///
|
||||
@@ -256,6 +277,31 @@ impl Board {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the solid at `(x, y)` can be **shifted** one step in `dir`: it is
|
||||
/// itself a pushable solid *and* the next cell is either empty or holds another
|
||||
/// pushable solid.
|
||||
///
|
||||
/// Unlike [`can_push`](Board::can_push) this inspects only the single cell
|
||||
/// ahead — it does **not** verify the whole chain ends in open space. It is the
|
||||
/// right test for a simultaneous rotation/shift (applied via
|
||||
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
|
||||
/// another pushable that is itself moving the same frame. Returns `false` if
|
||||
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
|
||||
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
|
||||
// The source must hold a solid pushable in `dir`.
|
||||
if !self.is_pushable(x, y, dir) {
|
||||
return false;
|
||||
}
|
||||
let (dx, dy): (i32, i32) = dir.into();
|
||||
let next = (x as i32 + dx, y as i32 + dy);
|
||||
if !self.in_bounds(next) {
|
||||
return false; // nothing to shift into off the board
|
||||
}
|
||||
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
||||
// The cell ahead is acceptable if it is empty or another pushable solid.
|
||||
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
|
||||
}
|
||||
|
||||
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
|
||||
/// leaving `Empty` floor behind each moved cell.
|
||||
///
|
||||
@@ -336,6 +382,18 @@ impl Board {
|
||||
id
|
||||
}
|
||||
|
||||
/// Removes the object with `id`, returning its [`ObjectDef`] if it existed.
|
||||
///
|
||||
/// This only touches the board's `objects` map. A live [`ScriptHost`] built
|
||||
/// before the removal keeps a stale `ObjectRuntime` for the gone object; its
|
||||
/// subsequent host-fn calls resolve to a missing id and become no-ops, so the
|
||||
/// removal is benign even mid-game (see CLAUDE.md's runtime spawn/destroy note).
|
||||
///
|
||||
/// [`ScriptHost`]: crate::script::ScriptHost
|
||||
pub fn remove_object(&mut self, id: ObjectId) -> Option<ObjectDef> {
|
||||
self.objects.remove(&id)
|
||||
}
|
||||
|
||||
/// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at
|
||||
/// `(x, y)`, applying the editor's placement/removal rules.
|
||||
///
|
||||
@@ -373,6 +431,194 @@ impl Board {
|
||||
*self.get_mut(z, x, y) = (glyph, arch);
|
||||
}
|
||||
|
||||
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
|
||||
/// `Spinner` or `Pusher`) with the scripted object it expands to — the same
|
||||
/// transformation the map loader applies in [`layer::build_layer`](crate::layer),
|
||||
/// but run against a live, already-built board.
|
||||
///
|
||||
/// The editor stamps these archetypes as plain terrain cells (via
|
||||
/// [`place_archetype`](Board::place_archetype)); they only come alive once
|
||||
/// expanded into objects carrying their embedded script + `BUILTIN_*` tag. Call
|
||||
/// this before running a board assembled in memory (e.g. entering a playtest), so
|
||||
/// editor-placed machines actually run. A save→reload round-trip expands them via
|
||||
/// the normal load path, so this is only needed for the in-memory path. Cells
|
||||
/// already loaded as objects are untouched, so it is safe to call more than once.
|
||||
pub fn expand_builtin_archetypes(&mut self) {
|
||||
use crate::builtin_scripts::{archetype_script, builtin_tag};
|
||||
// Collect first: the loop below mutates both layers and the object map.
|
||||
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
|
||||
for z in 0..self.layers.len() {
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
let (glyph, arch) = *self.get(z, x, y);
|
||||
if archetype_script(arch).is_some() {
|
||||
found.push((z, x, y, glyph, arch));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (z, x, y, glyph, arch) in found {
|
||||
// Vacate the terrain cell (revealing any floor beneath), then spawn the
|
||||
// object — mirroring `resolve_entry`'s object template.
|
||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||
let b = arch.behavior();
|
||||
let mut obj = ObjectDef::new(x, y);
|
||||
obj.z = z;
|
||||
obj.glyph = glyph;
|
||||
obj.solid = b.solid;
|
||||
obj.opaque = b.opaque;
|
||||
obj.pushable = false;
|
||||
obj.builtin_script = archetype_script(arch);
|
||||
obj.tags.insert(builtin_tag(arch));
|
||||
self.add_object(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a batch of one-way solid moves **simultaneously**, returning any
|
||||
/// nonfatal error lines (out-of-bounds entries / a write blocked by the player).
|
||||
///
|
||||
/// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x,
|
||||
/// src_y)` — the player, a solid object, or a terrain crate/wall — moves to
|
||||
/// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes**
|
||||
/// whatever solid was at the destination. Every source is read before any
|
||||
/// destination is written, so cyclic permutations and two-cell swaps resolve
|
||||
/// correctly (e.g. `[a→b],[b→a]` swaps `a` and `b`).
|
||||
///
|
||||
/// Displacement rules: a destination's prior solid that isn't itself being moved
|
||||
/// is removed (terrain cleared; a scripted object despawned via
|
||||
/// [`remove_object`](Board::remove_object)). The **player is never destroyed** —
|
||||
/// a write that would overwrite the player without relocating it is skipped and
|
||||
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
|
||||
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec<LogLine> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// 1. Validate: keep only entries whose source and destination are in bounds.
|
||||
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
|
||||
for &(sx, sy, dx, dy) in pairs {
|
||||
if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
valid.push(((sx, sy), (dx, dy)));
|
||||
}
|
||||
|
||||
// 2. Snapshot the solid at each unique source (read phase). Reading every
|
||||
// source before any write is what lets cycles/swaps resolve.
|
||||
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
||||
for &(src, _) in &valid {
|
||||
snapshots
|
||||
.entry(src)
|
||||
.or_insert_with(|| self.snapshot_solid(src));
|
||||
}
|
||||
|
||||
// 3. Compute the final occupant of every affected cell. A source that is not
|
||||
// anyone's destination is vacated (Empty); each entry writes its source's
|
||||
// snapshot into its destination (a later entry wins a repeated destination).
|
||||
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
|
||||
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
||||
for &(src, _) in &valid {
|
||||
if !dsts.contains(&src) {
|
||||
final_state.insert(src, SolidSnapshot::Empty);
|
||||
}
|
||||
}
|
||||
for &(src, dst) in &valid {
|
||||
final_state.insert(dst, snapshots[&src].clone());
|
||||
}
|
||||
|
||||
// The player's final cell: where a Player snapshot is installed, else its
|
||||
// current cell (it stays put). Used to protect the player from being
|
||||
// overwritten — computed up front so it's stable across the write loop.
|
||||
let player_final = final_state
|
||||
.iter()
|
||||
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
|
||||
.map(|(&c, _)| c)
|
||||
.unwrap_or((self.player.x, self.player.y));
|
||||
|
||||
// 4a. Clear each affected cell's current occupant, despawning any object that
|
||||
// doesn't survive (isn't reused in final_state). The player and surviving
|
||||
// objects keep their entity and are repositioned by the install step.
|
||||
let survivors: HashSet<ObjectId> = final_state
|
||||
.values()
|
||||
.filter_map(|s| match s {
|
||||
SolidSnapshot::Object(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let affected: HashSet<(i32, i32)> = snapshots
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(final_state.keys().copied())
|
||||
.collect();
|
||||
for &(cx, cy) in &affected {
|
||||
let (ux, uy) = (cx as usize, cy as usize);
|
||||
match self.solid_at(ux, uy) {
|
||||
Some(Solid::Object(id)) if !survivors.contains(&id) => {
|
||||
self.remove_object(id);
|
||||
}
|
||||
Some(Solid::Cell(_)) => {
|
||||
if let Some(z) = self.solid_cell_layer(ux, uy) {
|
||||
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. Install each cell's computed occupant.
|
||||
for (&(cx, cy), snap) in &final_state {
|
||||
let (ux, uy) = (cx as usize, cy as usize);
|
||||
// The player wins its cell: never overwrite player_final with anything
|
||||
// other than the player itself.
|
||||
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"swap: cannot overwrite the player at ({cx},{cy})"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
match snap {
|
||||
SolidSnapshot::Empty => {} // already cleared
|
||||
SolidSnapshot::Terrain { z, glyph, arch } => {
|
||||
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
|
||||
}
|
||||
SolidSnapshot::Object(id) => {
|
||||
if let Some(obj) = self.objects.get_mut(id) {
|
||||
obj.x = ux;
|
||||
obj.y = uy;
|
||||
}
|
||||
}
|
||||
SolidSnapshot::Player => {
|
||||
self.player.x = cx;
|
||||
self.player.y = cy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
|
||||
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
|
||||
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
|
||||
let (ux, uy) = (x as usize, y as usize);
|
||||
match self.solid_at(ux, uy) {
|
||||
Some(Solid::Player) => SolidSnapshot::Player,
|
||||
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
|
||||
Some(Solid::Cell(arch)) => {
|
||||
let z = self
|
||||
.solid_cell_layer(ux, uy)
|
||||
.expect("a solid terrain cell has a layer");
|
||||
SolidSnapshot::Terrain {
|
||||
z,
|
||||
glyph: self.get(z, ux, uy).0,
|
||||
arch,
|
||||
}
|
||||
}
|
||||
None => SolidSnapshot::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
||||
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
|
||||
/// invariant there is at most one such layer.
|
||||
@@ -384,7 +630,7 @@ impl Board {
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::Board;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::archetype::{Archetype, SpinDirection};
|
||||
use crate::glyph::Glyph;
|
||||
use crate::layer::Layer;
|
||||
use crate::object_def::ObjectDef;
|
||||
@@ -538,6 +784,38 @@ pub(crate) mod tests {
|
||||
assert!(!board.can_push(1, 0, Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_shift_only_checks_the_cell_ahead() {
|
||||
// Source must be pushable.
|
||||
let mut board = open_board(4, 1, (3, 0), vec![]);
|
||||
assert!(!board.can_shift(0, 0, Direction::East)); // empty source
|
||||
|
||||
// Crate with open space ahead: shiftable.
|
||||
crate_at(&mut board, 0, 0);
|
||||
assert!(board.can_shift(0, 0, Direction::East));
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
|
||||
|
||||
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
|
||||
// which would follow the chain to the wall and fail).
|
||||
let mut board = open_board(4, 1, (3, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
crate_at(&mut board, 1, 0);
|
||||
wall_at(&mut board, 2, 0);
|
||||
assert!(board.can_shift(0, 0, Direction::East));
|
||||
assert!(!board.can_push(0, 0, Direction::East));
|
||||
|
||||
// Crate with a non-pushable wall ahead: not shiftable.
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
wall_at(&mut board, 1, 0);
|
||||
assert!(!board.can_shift(0, 0, Direction::East));
|
||||
|
||||
// Crate at the board edge facing off-board: not shiftable.
|
||||
let mut board = open_board(2, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
assert!(!board.can_shift(1, 0, Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_into_player_pushes_player() {
|
||||
// Crate shoved east into the player slides the player along into open space.
|
||||
@@ -673,4 +951,117 @@ pub(crate) mod tests {
|
||||
assert!(!board.is_valid());
|
||||
assert_eq!(board.load_errors.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_builtin_archetypes_replaces_a_spinner_cell_with_an_object() {
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
stamp(
|
||||
&mut board,
|
||||
0,
|
||||
0,
|
||||
Archetype::Spinner(SpinDirection::Clockwise),
|
||||
);
|
||||
board.expand_builtin_archetypes();
|
||||
|
||||
// The terrain cell is vacated and a scripted object takes its place.
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||
let obj = board.objects.values().next().expect("spinner object");
|
||||
assert_eq!((obj.x, obj.y), (0, 0));
|
||||
assert!(obj.solid);
|
||||
assert!(obj.builtin_script.is_some());
|
||||
assert!(obj.tags.contains("BUILTIN_spinner_cw"));
|
||||
|
||||
// Idempotent: nothing left to expand on a second pass.
|
||||
board.expand_builtin_archetypes();
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_object_deletes_from_map() {
|
||||
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(0, 0)]);
|
||||
assert!(board.solid_object_id_at(0, 0).is_some());
|
||||
let removed = board.remove_object(1);
|
||||
assert!(removed.is_some());
|
||||
assert!(board.solid_object_id_at(0, 0).is_none());
|
||||
assert!(board.remove_object(1).is_none()); // already gone
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_swaps_two_terrain_cells() {
|
||||
// A crate and a wall trade places in one batch (read-all then write-all).
|
||||
let mut board = open_board(3, 1, (1, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_cycle_propagates_empty() {
|
||||
// The spec example: move a->b and b->c with a empty ⇒ a empty, b empty,
|
||||
// c holds what b held (the empty written into b doesn't block b->c).
|
||||
let mut board = open_board(4, 1, (3, 0), vec![]);
|
||||
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
|
||||
crate_at(&mut board, 1, 0);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_empty_onto_object_removes_it() {
|
||||
// Moving an empty source onto an object despawns the object.
|
||||
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
||||
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert!(board.solid_object_id_at(1, 0).is_none());
|
||||
assert!(board.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_terrain_onto_terrain_removes_displaced() {
|
||||
// Moving a crate onto a wall clears the source and removes the wall.
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
wall_at(&mut board, 1, 0);
|
||||
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_moves_object_and_player() {
|
||||
// An object and the player relocate (swap places) in one batch.
|
||||
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
|
||||
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
||||
assert!(errs.is_empty());
|
||||
assert_eq!((board.player.x, board.player.y), (2, 0));
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_out_of_bounds_skips_and_logs() {
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
let errs = board.apply_swap(&[(0, 0, 9, 0)]);
|
||||
assert_eq!(errs.len(), 1);
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_swap_will_not_overwrite_player() {
|
||||
// A crate moved onto the (non-relocating) player is rejected and logged.
|
||||
let mut board = open_board(3, 1, (1, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||
assert_eq!(errs.len(), 1);
|
||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user