playtest mode

This commit is contained in:
2026-06-20 19:05:46 -05:00
parent e7c3ca1139
commit d32914d99d
10 changed files with 168 additions and 9 deletions
+6
View File
@@ -24,6 +24,12 @@ use std::collections::{BTreeMap, HashMap};
/// separate element palette or index indirection. Access cells with
/// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates.
/// Use [`Board::is_passable`] for collision checks.
///
/// `Board` derives [`Clone`] to support deep-copying a whole [`World`](crate::world::World)
/// (see [`World::deep_clone`](crate::world::World::deep_clone)) — e.g. the editor's
/// playtest runs a game against an isolated copy so play mutations never touch the
/// boards being edited.
#[derive(Clone)]
pub struct Board {
/// Human-readable name for this board, loaded from the map file and round-tripped on save.
pub name: String,
+1
View File
@@ -29,6 +29,7 @@ use tinyrand::StdRand;
/// it draws nothing and lets the layer beneath show through. Solidity comes from
/// the archetype and is independent of transparency. The grid is `width * height`
/// long; index it via the owning [`Board`](crate::board::Board)'s dimensions.
#[derive(Clone)]
pub(crate) struct Layer {
/// Row-major `(Glyph, Archetype)` cells for this layer.
pub(crate) cells: Vec<(Glyph, Archetype)>,
+1
View File
@@ -22,6 +22,7 @@ use std::collections::HashSet;
/// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
#[derive(Clone)]
pub struct ObjectDef {
/// Stable identity assigned by [`crate::board::Board::add_object`].
/// `0` is the sentinel meaning "not yet inserted into a board".
+66
View File
@@ -36,6 +36,29 @@ pub struct World {
pub boards: HashMap<String, Rc<RefCell<Board>>>,
}
impl World {
/// Returns a fully independent copy of this world.
///
/// Unlike a shallow clone (which would copy each board's `Rc` and so keep
/// *sharing* the underlying [`Board`]s), this rebuilds every board into a brand
/// new `Rc<RefCell<Board>>`, so mutating the copy never affects the original.
/// The editor uses it to playtest a board against an isolated game state, leaving
/// the boards being edited untouched. (An explicit method rather than a `Clone`
/// impl, precisely because the shallow, `Rc`-sharing behavior would be surprising.)
pub fn deep_clone(&self) -> World {
World {
name: self.name.clone(),
start: self.start.clone(),
scripts: self.scripts.clone(),
boards: self
.boards
.iter()
.map(|(key, board)| (key.clone(), Rc::new(RefCell::new(board.borrow().clone()))))
.collect(),
}
}
}
/// Serde target for the top-level `.toml` world file.
#[derive(Deserialize)]
struct WorldFile {
@@ -91,3 +114,46 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
boards,
})
}
#[cfg(test)]
mod tests {
use super::World;
use crate::archetype::Archetype;
use crate::board::tests::open_board;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
/// A `deep_clone`d world owns independent boards: mutating the copy must leave the
/// original board untouched (the property the editor's playtest relies on).
#[test]
fn deep_clone_isolates_boards() {
let board = open_board(3, 1, (0, 0), vec![]);
let mut boards = HashMap::new();
boards.insert("start".to_string(), Rc::new(RefCell::new(board)));
let world = World {
name: "w".into(),
start: "start".into(),
scripts: HashMap::new(),
boards,
};
let copy = world.deep_clone();
// Stamp a wall into the copy's board.
copy.boards["start"].borrow_mut().place_archetype(
1,
0,
Archetype::Wall,
Archetype::Wall.default_glyph(),
);
// The copy changed; the original is still empty at that cell.
assert_eq!(copy.boards["start"].borrow().get(0, 1, 0).1, Archetype::Wall);
assert_eq!(
world.boards["start"].borrow().get(0, 1, 0).1,
Archetype::Empty
);
// And they are genuinely different allocations.
assert!(!Rc::ptr_eq(&world.boards["start"], &copy.boards["start"]));
}
}