multi board
This commit is contained in:
+17
-24
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::archetype::Archetype::Empty;
|
||||
use crate::font::FontSpec;
|
||||
@@ -61,16 +61,11 @@ pub struct Board {
|
||||
pub font: Option<FontSpec>,
|
||||
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
|
||||
pub zoom: u32,
|
||||
/// Named Rhai scripts available on this board: script name → source text.
|
||||
///
|
||||
/// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`]
|
||||
/// reference entries by name. This means multiple objects can share the same
|
||||
/// source while each running instance gets its own Rhai scope at runtime.
|
||||
pub scripts: HashMap<String, String>,
|
||||
/// Name of the board-level script in [`Board::scripts`], if any.
|
||||
/// Name of the board-level script in the world script pool, if any.
|
||||
///
|
||||
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
|
||||
/// rather than being tied to a specific object cell.
|
||||
/// rather than being tied to a specific object cell. Scripts live in
|
||||
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
||||
pub board_script_name: Option<String>,
|
||||
/// Nonfatal problems collected while loading this map (e.g. unknown
|
||||
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
||||
@@ -313,7 +308,7 @@ impl Board {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
use color::Rgba8;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::glyph::Glyph;
|
||||
@@ -322,14 +317,13 @@ pub(crate) mod tests {
|
||||
use crate::utils::{ObjectId, Player, Solid};
|
||||
use super::Board;
|
||||
|
||||
/// Builds an all-empty `w×h` board with the given player position, objects,
|
||||
/// and `(name, source)` scripts. Assigns sequential ids (1..=n) to objects.
|
||||
/// Builds an all-empty `w×h` board with the given player position and objects.
|
||||
/// Assigns sequential ids (1..=n) to objects.
|
||||
pub(crate) fn open_board(
|
||||
w: usize,
|
||||
h: usize,
|
||||
player: (i32, i32),
|
||||
objects: Vec<ObjectDef>,
|
||||
scripts: &[(&str, &str)],
|
||||
) -> Board {
|
||||
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
||||
let mut next_object_id: ObjectId = 1;
|
||||
@@ -351,7 +345,6 @@ pub(crate) mod tests {
|
||||
portals: Vec::new(),
|
||||
font: None,
|
||||
zoom: 1,
|
||||
scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<HashMap<_,_>>(),
|
||||
board_script_name: None,
|
||||
load_errors: Vec::new(),
|
||||
}
|
||||
@@ -374,7 +367,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn solid_at_reports_wall_object_and_empty() {
|
||||
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (3, 0), vec![]);
|
||||
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
||||
board.add_object(ObjectDef::new(2, 0));
|
||||
|
||||
@@ -398,14 +391,14 @@ pub(crate) mod tests {
|
||||
fn non_solid_object_does_not_block() {
|
||||
let mut obj = ObjectDef::new(1, 0);
|
||||
obj.solid = false;
|
||||
let board = open_board(3, 1, (0, 0), vec![obj], &[]);
|
||||
let board = open_board(3, 1, (0, 0), vec![obj]);
|
||||
assert!(board.solid_at(1, 0).is_none());
|
||||
assert!(board.is_passable(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_at_reports_player() {
|
||||
let board = open_board(3, 1, (1, 0), vec![], &[]);
|
||||
let board = open_board(3, 1, (1, 0), vec![]);
|
||||
match board.solid_at(1, 0) {
|
||||
Some(Solid::Player) => {}
|
||||
_ => panic!("expected Solid::Player at the player's cell"),
|
||||
@@ -415,7 +408,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn in_bounds_checks_grid_boundaries() {
|
||||
let board = open_board(3, 2, (0, 0), vec![], &[]);
|
||||
let board = open_board(3, 2, (0, 0), vec![]);
|
||||
assert!(board.in_bounds((0, 0)));
|
||||
assert!(board.in_bounds((2, 1)));
|
||||
assert!(!board.in_bounds((-1, 0)));
|
||||
@@ -426,13 +419,13 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn can_push_is_read_only_and_correct() {
|
||||
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
assert!(board.can_push(1, 0, Direction::East));
|
||||
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
|
||||
assert_eq!(board.get(2, 0).1, Archetype::Empty);
|
||||
|
||||
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
wall_at(&mut board, 2, 0);
|
||||
assert!(!board.can_push(1, 0, Direction::East));
|
||||
@@ -441,7 +434,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn push_into_player_pushes_player() {
|
||||
// Crate shoved east into the player slides the player along into open space.
|
||||
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
assert!(board.can_push(1, 0, Direction::East));
|
||||
board.push(1, 0, Direction::East);
|
||||
@@ -453,7 +446,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn push_into_player_blocked_by_wall() {
|
||||
// Player backed against a wall: push has nowhere to go, nothing moves.
|
||||
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
wall_at(&mut board, 3, 0);
|
||||
assert!(!board.can_push(1, 0, Direction::East));
|
||||
@@ -465,7 +458,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
|
||||
// Player parked at (2,0) so it doesn't overlap either asserted cell.
|
||||
let mut board = open_board(3, 1, (2, 0), vec![], &[]);
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
let floor_glyph = Glyph {
|
||||
tile: '.' as u32,
|
||||
fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 },
|
||||
@@ -479,7 +472,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn fresh_board_is_valid_and_reports_errors() {
|
||||
let mut board = open_board(1, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(1, 1, (0, 0), vec![]);
|
||||
assert!(board.is_valid());
|
||||
board.report_error("something went wrong");
|
||||
assert!(!board.is_valid());
|
||||
|
||||
+58
-24
@@ -1,10 +1,10 @@
|
||||
use crate::action::Action;
|
||||
use crate::board::Board;
|
||||
use crate::log::LogLine;
|
||||
use crate::script::ScriptHost;
|
||||
use std::cell::{Ref, RefCell, RefMut};
|
||||
use std::rc::Rc;
|
||||
use crate::world::World;
|
||||
use std::cell::{Ref, RefMut};
|
||||
use std::time::Duration;
|
||||
use crate::board::Board;
|
||||
use crate::utils::{Direction, ObjectId, ScriptArg, Solid};
|
||||
|
||||
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||||
@@ -38,18 +38,19 @@ pub struct SpeechBubble {
|
||||
/// Holds the active game world and provides game-logic operations.
|
||||
///
|
||||
/// `GameState` is the boundary between the engine (rendering, input) and the
|
||||
/// game data. It owns the board (behind a shared `Rc<RefCell<Board>>`),
|
||||
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
|
||||
/// internal logic reach the board through [`board`](GameState::board) /
|
||||
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
|
||||
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
|
||||
/// after each script batch.
|
||||
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
|
||||
/// board is currently active. Front-ends reach the active board through
|
||||
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
|
||||
/// read the board and queue mutations via commands applied by `resolve` after
|
||||
/// each script batch.
|
||||
pub struct GameState {
|
||||
/// The scriptable world, shared with the script host's read getters.
|
||||
board: Rc<RefCell<Board>>,
|
||||
/// All boards and world-level scripts.
|
||||
world: World,
|
||||
/// Key of the currently active board in [`world.boards`](World::boards).
|
||||
current_board_name: String,
|
||||
/// The in-game message log, oldest first (newest pushed at the end).
|
||||
pub log: Vec<LogLine>,
|
||||
/// The Rhai scripting runtime driving this board's scripted objects.
|
||||
/// The Rhai scripting runtime for the active board's objects.
|
||||
scripts: ScriptHost,
|
||||
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
|
||||
pub speech_bubbles: Vec<SpeechBubble>,
|
||||
@@ -59,34 +60,67 @@ pub struct GameState {
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a `GameState` from a pre-loaded [`Board`].
|
||||
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
|
||||
///
|
||||
/// Compiles the board's object scripts but does **not** run any of them;
|
||||
/// call [`GameState::run_init`] once the game is ready to start. Any script
|
||||
/// compile errors are surfaced into the log here.
|
||||
pub fn new(board: Board) -> Self {
|
||||
let board = Rc::new(RefCell::new(board));
|
||||
let scripts = ScriptHost::new(&board);
|
||||
/// The starting board's objects are compiled and registered with the
|
||||
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
|
||||
/// call [`run_init`](GameState::run_init) once the game is ready to start.
|
||||
/// Compile errors are surfaced into the log immediately.
|
||||
pub fn from_world(world: World) -> Self {
|
||||
let name = world.start.clone();
|
||||
let host = ScriptHost::new(
|
||||
world.boards.get(&name).expect("world::load guarantees start board exists"),
|
||||
&world.scripts,
|
||||
);
|
||||
let mut state = Self {
|
||||
board,
|
||||
world,
|
||||
current_board_name: name,
|
||||
log: Vec::new(),
|
||||
scripts,
|
||||
scripts: host,
|
||||
speech_bubbles: Vec::new(),
|
||||
active_scroll: None,
|
||||
};
|
||||
// Surface any compile-time script errors collected during ScriptHost::new.
|
||||
state.drain_errors();
|
||||
state
|
||||
}
|
||||
|
||||
/// Creates a `GameState` from a single [`Board`] with no scripts.
|
||||
///
|
||||
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
|
||||
#[cfg(test)]
|
||||
pub fn new(board: Board) -> Self {
|
||||
Self::with_scripts(board, std::collections::HashMap::new())
|
||||
}
|
||||
|
||||
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
|
||||
///
|
||||
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
|
||||
#[cfg(test)]
|
||||
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
|
||||
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
|
||||
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
|
||||
let world = World {
|
||||
name: String::new(),
|
||||
start: "board".to_string(),
|
||||
scripts,
|
||||
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
|
||||
};
|
||||
Self::from_world(world)
|
||||
}
|
||||
|
||||
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
||||
pub fn board(&self) -> Ref<'_, Board> {
|
||||
self.board.borrow()
|
||||
self.world.boards[&self.current_board_name].borrow()
|
||||
}
|
||||
|
||||
/// Borrows the active board for mutation.
|
||||
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
||||
self.board.borrow_mut()
|
||||
self.world.boards[&self.current_board_name].borrow_mut()
|
||||
}
|
||||
|
||||
/// The name (key) of the currently active board within the world.
|
||||
pub fn current_board_name(&self) -> &str {
|
||||
&self.current_board_name
|
||||
}
|
||||
|
||||
/// Appends a styled message to the log.
|
||||
|
||||
@@ -41,10 +41,6 @@ pub struct MapFile {
|
||||
/// Any `[[portals]]` entries. Optional; defaults to empty.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub portals: Vec<PortalDef>,
|
||||
/// The `[scripts]` table: maps script names to Rhai source text.
|
||||
/// Optional; defaults to empty.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub scripts: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The `[map]` header section of a map file.
|
||||
@@ -570,7 +566,6 @@ impl TryFrom<MapFile> for Board {
|
||||
portals: mf.portals,
|
||||
font,
|
||||
zoom: mf.map.zoom,
|
||||
scripts: mf.scripts,
|
||||
board_script_name: mf.map.board_script_name,
|
||||
load_errors,
|
||||
})
|
||||
@@ -705,7 +700,6 @@ impl From<&Board> for MapFile {
|
||||
floor: board.floor_spec.clone(),
|
||||
objects,
|
||||
portals,
|
||||
scripts: board.scripts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ impl ScriptHost {
|
||||
/// compiles every script referenced by an object (once each), and creates a fresh
|
||||
/// scope and output queue per scripted object. Compile errors and references to
|
||||
/// unknown scripts are queued onto the error sink; no script is run here.
|
||||
pub fn new(board_cell: &BoardRef) -> Self {
|
||||
///
|
||||
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
||||
/// read only during construction and not retained afterward.
|
||||
pub fn new(board_cell: &BoardRef, script_sources: &HashMap<String, String>) -> Self {
|
||||
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
|
||||
let queues: QueueMap = Rc::new(RefCell::new(HashMap::new()));
|
||||
let errors: ErrorSink = Rc::new(RefCell::new(Vec::new()));
|
||||
@@ -186,7 +189,7 @@ impl ScriptHost {
|
||||
for obj in board.objects.values() {
|
||||
let Some(name) = obj.script_name.as_ref() else { continue; };
|
||||
if scripts.contains_key(name) || failed.contains(name) { continue; }
|
||||
match board.scripts.get(name) {
|
||||
match script_sources.get(name) {
|
||||
Some(src) => match engine.compile(src) {
|
||||
Ok(ast) => {
|
||||
let defines = |n: &str, params: usize| {
|
||||
|
||||
+38
-114
@@ -2,18 +2,12 @@ use std::time::Duration;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use crate::game::GameState;
|
||||
use super::{scripted_object, log_texts};
|
||||
use super::{scripted_object, log_texts, scripts_from};
|
||||
|
||||
#[test]
|
||||
fn move_command_relocates_the_source_object() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(2, 1, "m")],
|
||||
&[("m", "fn init() { move(East); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
// East increments x by one; the object started at (2, 1).
|
||||
@@ -23,28 +17,16 @@ fn move_command_relocates_the_source_object() {
|
||||
#[test]
|
||||
fn move_into_a_wall_or_edge_is_a_noop() {
|
||||
// Object at the west edge moving west: out of bounds, ignored.
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(0, 1, "m")],
|
||||
&[("m", "fn init() { move(West); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tile_command_changes_the_source_glyph() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(2, 1, "s")],
|
||||
&[("s", "fn init() { set_tile(7); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
@@ -52,15 +34,9 @@ fn set_tile_command_changes_the_source_glyph() {
|
||||
#[test]
|
||||
fn object_pushes_crate_on_init() {
|
||||
// A scripted object moving east into a crate shoves it (step_object path).
|
||||
let mut board = open_board(
|
||||
4,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); }")],
|
||||
);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
|
||||
@@ -71,14 +47,8 @@ fn object_pushes_crate_on_init() {
|
||||
#[test]
|
||||
fn object_push_into_player() {
|
||||
// A scripted object moving into the player pushes the player when there's room.
|
||||
let board = open_board(
|
||||
5,
|
||||
1,
|
||||
(2, 0),
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
game.run_init();
|
||||
{
|
||||
let b = game.board();
|
||||
@@ -87,15 +57,9 @@ fn object_push_into_player() {
|
||||
}
|
||||
|
||||
// With a wall behind the player, the object is blocked and nothing moves.
|
||||
let mut board = open_board(
|
||||
4,
|
||||
1,
|
||||
(2, 0),
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); }")],
|
||||
);
|
||||
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
|
||||
wall_at(&mut board, 3, 0);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")]));
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
|
||||
@@ -106,14 +70,8 @@ fn object_push_into_player() {
|
||||
fn move_cost_rate_limits_repeated_moves() {
|
||||
// init queues two moves; only the first resolves immediately, the second must
|
||||
// wait the full 250 ms cooldown.
|
||||
let board = open_board(
|
||||
5,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); move(East); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
|
||||
|
||||
@@ -131,15 +89,9 @@ fn move_cost_rate_limits_repeated_moves() {
|
||||
fn inline_delay_paces_subsequent_moves() {
|
||||
// move() always appends a Delay(250 ms) to the queue, so a second queued move
|
||||
// is held back by that delay even if the first move was blocked by a wall.
|
||||
let mut board = open_board(
|
||||
3,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 1, "m")],
|
||||
&[("m", "fn init() { move(East); move(South); }")],
|
||||
);
|
||||
let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]);
|
||||
wall_at(&mut board, 2, 1);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")]));
|
||||
game.run_init();
|
||||
// First (eastward) move is blocked by the wall: object hasn't moved.
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
|
||||
@@ -158,17 +110,11 @@ fn inline_delay_paces_subsequent_moves() {
|
||||
fn queue_length_reports_pending_actions() {
|
||||
// Two zero-cost actions are queued before the length is read, then all three
|
||||
// (incl. the log) drain in one pump.
|
||||
let board = open_board(
|
||||
3,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "q")],
|
||||
&[(
|
||||
"q",
|
||||
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"q",
|
||||
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
||||
)]));
|
||||
game.run_init();
|
||||
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won
|
||||
@@ -177,14 +123,8 @@ fn queue_length_reports_pending_actions() {
|
||||
#[test]
|
||||
fn queue_clear_drops_pending_actions() {
|
||||
// clear() empties the output queue mid-script, so the queued moves never run.
|
||||
let board = open_board(
|
||||
5,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "c")],
|
||||
&[("c", "fn init() { move(East); move(East); Queue.clear(); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(300));
|
||||
assert_eq!(game.board().objects[&1].x, 1); // never moved
|
||||
@@ -193,33 +133,21 @@ fn queue_clear_drops_pending_actions() {
|
||||
#[test]
|
||||
fn blocked_reports_solid_and_clear() {
|
||||
// Solid ahead (a wall): blocked() is true.
|
||||
let mut board = open_board(
|
||||
3,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "b")],
|
||||
&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)],
|
||||
);
|
||||
let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 9);
|
||||
|
||||
// Open ahead, nothing pending: blocked() is false.
|
||||
let board = open_board(
|
||||
3,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "b")],
|
||||
&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[(
|
||||
"b",
|
||||
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
@@ -236,15 +164,11 @@ fn blocked_sees_earlier_objects_pending_move() {
|
||||
scripted_object(1, 1, "mover"),
|
||||
scripted_object(3, 1, "checker"),
|
||||
],
|
||||
&[
|
||||
("mover", "fn tick(dt) { move(East); }"),
|
||||
(
|
||||
"checker",
|
||||
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("mover", "fn tick(dt) { move(East); }"),
|
||||
("checker", "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }"),
|
||||
]));
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::time::Duration;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use super::{scripted_object, log_texts};
|
||||
use super::{scripted_object, log_texts, scripts_from};
|
||||
|
||||
#[test]
|
||||
fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
@@ -12,18 +12,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
2,
|
||||
(0, 1), // player off the contested row
|
||||
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
|
||||
&[
|
||||
(
|
||||
"e",
|
||||
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
|
||||
),
|
||||
(
|
||||
"w",
|
||||
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("e", "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }"),
|
||||
("w", "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }"),
|
||||
]));
|
||||
game.run_init();
|
||||
// The bump fires during resolution but its log is emitted into obj0's queue;
|
||||
// a later tick (past both objects' move cooldown) flushes it to the game log.
|
||||
|
||||
@@ -4,20 +4,22 @@ mod map_file;
|
||||
mod movement;
|
||||
mod scripting;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Player;
|
||||
use crate::game::GameState;
|
||||
|
||||
/// Builds a 1×1 board with a single object that optionally references a
|
||||
/// script, plus the given `(name, source)` script table entries.
|
||||
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
|
||||
/// Builds a 1×1 board with a single object that optionally references a script,
|
||||
/// and returns both the board and the `(name, source)` entries as a script map.
|
||||
///
|
||||
/// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled.
|
||||
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (Board, HashMap<String, String>) {
|
||||
let mut object = ObjectDef::new(0, 0);
|
||||
object.id = 1;
|
||||
object.script_name = object_script.map(str::to_string);
|
||||
Board {
|
||||
let board = Board {
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -30,10 +32,16 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> B
|
||||
portals: Vec::new(),
|
||||
font: None,
|
||||
zoom: 1,
|
||||
scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
|
||||
board_script_name: None,
|
||||
load_errors: Vec::new(),
|
||||
}
|
||||
};
|
||||
(board, scripts_from(scripts))
|
||||
}
|
||||
|
||||
/// Converts a `(name, source)` slice into a `HashMap` suitable for
|
||||
/// [`GameState::with_scripts`].
|
||||
fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
|
||||
|
||||
@@ -11,7 +11,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||
// The cell a crate is pushed off of becomes Empty and glyph_at shows floor.
|
||||
// After the push the player is at (1,0); check the cell the player vacated
|
||||
// (0,0) — it is Empty and must reveal the floor glyph, not black-on-black.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
let floor_glyph = Glyph {
|
||||
tile: ',' as u32,
|
||||
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
|
||||
@@ -29,7 +29,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||
|
||||
#[test]
|
||||
fn player_pushes_single_crate() {
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
@@ -41,7 +41,7 @@ fn player_pushes_single_crate() {
|
||||
|
||||
#[test]
|
||||
fn push_blocked_by_wall_moves_nothing() {
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
@@ -55,7 +55,7 @@ fn push_blocked_by_wall_moves_nothing() {
|
||||
#[test]
|
||||
fn push_blocked_by_edge_moves_nothing() {
|
||||
// Crate on the last column; player pushes it toward the board edge.
|
||||
let mut board = open_board(2, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(2, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
@@ -66,7 +66,7 @@ fn push_blocked_by_edge_moves_nothing() {
|
||||
|
||||
#[test]
|
||||
fn cascade_pushes_two_crates() {
|
||||
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(5, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
@@ -80,7 +80,7 @@ fn cascade_pushes_two_crates() {
|
||||
|
||||
#[test]
|
||||
fn cascade_blocked_by_wall_moves_nothing() {
|
||||
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(5, 1, (0, 0), vec![]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
crate_at(&mut board, 2, 0);
|
||||
wall_at(&mut board, 3, 0);
|
||||
@@ -97,7 +97,7 @@ fn player_pushes_pushable_solid_object() {
|
||||
// A solid, pushable object is shoved exactly like a crate.
|
||||
let mut obj = ObjectDef::new(1, 0);
|
||||
obj.pushable = true;
|
||||
let board = open_board(4, 1, (0, 0), vec![obj], &[]);
|
||||
let board = open_board(4, 1, (0, 0), vec![obj]);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
@@ -108,7 +108,7 @@ fn player_pushes_pushable_solid_object() {
|
||||
#[test]
|
||||
fn hcrate_pushes_east_but_not_north() {
|
||||
// Pushing east: the HCrate slides.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
stamp(&mut board, 1, 0, Archetype::HCrate);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
@@ -119,7 +119,7 @@ fn hcrate_pushes_east_but_not_north() {
|
||||
drop(b);
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
|
||||
let mut board = open_board(1, 4, (0, 3), vec![]);
|
||||
stamp(&mut board, 0, 2, Archetype::HCrate);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::North);
|
||||
@@ -131,7 +131,7 @@ fn hcrate_pushes_east_but_not_north() {
|
||||
#[test]
|
||||
fn vcrate_pushes_north_but_not_east() {
|
||||
// Pushing north: the VCrate slides.
|
||||
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
|
||||
let mut board = open_board(1, 4, (0, 3), vec![]);
|
||||
stamp(&mut board, 0, 2, Archetype::VCrate);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::North);
|
||||
@@ -142,7 +142,7 @@ fn vcrate_pushes_north_but_not_east() {
|
||||
drop(b);
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
stamp(&mut board, 1, 0, Archetype::VCrate);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
|
||||
@@ -2,15 +2,15 @@ use std::time::Duration;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::{GameState, ScrollLine};
|
||||
use crate::utils::Direction;
|
||||
use super::{board_with_object, scripted_object, log_texts};
|
||||
use super::{board_with_object, scripted_object, log_texts, scripts_from};
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let board = board_with_object(
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("greet"),
|
||||
&[("greet", r#"fn init() { log("hello"); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
// init must not fire during construction / deserialization.
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
@@ -20,11 +20,11 @@ fn init_runs_only_on_run_init_not_at_construction() {
|
||||
|
||||
#[test]
|
||||
fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
let board = board_with_object(
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
@@ -37,16 +37,18 @@ fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
#[test]
|
||||
fn missing_hooks_and_no_script_are_noops() {
|
||||
// Object with no script: nothing happens.
|
||||
let mut game = GameState::new(board_with_object(None, &[]));
|
||||
let (board, scripts) = board_with_object(None, &[]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
// Script defines neither init nor tick: also a no-op.
|
||||
let mut game = GameState::new(board_with_object(
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("e"),
|
||||
&[("e", "fn other() { log(\"x\"); }")],
|
||||
));
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
@@ -55,11 +57,13 @@ fn missing_hooks_and_no_script_are_noops() {
|
||||
#[test]
|
||||
fn compile_and_unknown_script_errors_are_logged() {
|
||||
// A reference to a script name that isn't in the table.
|
||||
let game = GameState::new(board_with_object(Some("ghost"), &[]));
|
||||
let (board, scripts) = board_with_object(Some("ghost"), &[]);
|
||||
let game = GameState::with_scripts(board, scripts);
|
||||
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
|
||||
|
||||
// A script that fails to compile is reported at construction time.
|
||||
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
||||
let (board, scripts) = board_with_object(Some("bad"), &[("bad", "fn init( {")]);
|
||||
let game = GameState::with_scripts(board, scripts);
|
||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||
}
|
||||
|
||||
@@ -70,9 +74,8 @@ fn script_reads_board_through_view() {
|
||||
3,
|
||||
(3, 1),
|
||||
vec![scripted_object(2, 1, "r")],
|
||||
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["3"]);
|
||||
}
|
||||
@@ -86,12 +89,11 @@ fn commands_are_routed_to_their_own_source_object() {
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
|
||||
&[
|
||||
("e", "fn init() { move(East); }"),
|
||||
("w", "fn init() { move(West); }"),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("e", "fn init() { move(East); }"),
|
||||
("w", "fn init() { move(West); }"),
|
||||
]));
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!(b.objects[&1].x, 1); // moved east from 0
|
||||
@@ -104,9 +106,8 @@ fn start_map_greeter_runs_init() {
|
||||
// confirm the greeter read the board (interpolated message) and wrote to
|
||||
// itself (set_tile). Also guards the example from drifting out of sync.
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
||||
let mut world = crate::world::load(path).expect("load start.toml");
|
||||
let board = world.boards.remove(&world.start).expect("start board");
|
||||
let mut game = GameState::new(board);
|
||||
let world = crate::world::load(path).expect("load start.toml");
|
||||
let mut game = GameState::from_world(world);
|
||||
game.run_init();
|
||||
assert!(
|
||||
log_texts(&game).iter().any(|t| t.contains("hello from object")),
|
||||
@@ -122,25 +123,22 @@ fn start_map_greeter_runs_init() {
|
||||
fn set_tag_adds_and_removes_via_my_id() {
|
||||
// A script calls set_tag(Me.id, "active", true) in init; the tag must be
|
||||
// present on the object afterward.
|
||||
let board = board_with_object(
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert!(game.board().objects[&1].tags.contains("active"));
|
||||
|
||||
// A script removes a pre-existing tag.
|
||||
let board2 = board_with_object(
|
||||
let (mut board2, scripts2) = board_with_object(
|
||||
Some("t2"),
|
||||
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
|
||||
);
|
||||
// Seed the tag before construction.
|
||||
let mut game2 = GameState::new({
|
||||
let mut b = board2; // need mut to insert tag
|
||||
b.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
|
||||
b
|
||||
});
|
||||
board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
|
||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||
game2.run_init();
|
||||
assert!(!game2.board().objects[&1].tags.contains("active"));
|
||||
}
|
||||
@@ -149,7 +147,7 @@ fn set_tag_adds_and_removes_via_my_id() {
|
||||
fn has_tag_reads_own_tags() {
|
||||
// set_tag queues an action; has_tag reads the board state. So set_tag in
|
||||
// init() takes effect after init returns; a subsequent tick sees it.
|
||||
let board = board_with_object(
|
||||
let (board, scripts) = board_with_object(
|
||||
Some("t"),
|
||||
&[(
|
||||
"t",
|
||||
@@ -157,7 +155,7 @@ fn has_tag_reads_own_tags() {
|
||||
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "true"));
|
||||
@@ -171,21 +169,15 @@ fn objects_with_tag_returns_matching_ids() {
|
||||
let mut obj2 = scripted_object(1, 0, "none");
|
||||
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
|
||||
obj2.tags.insert("enemy".to_string());
|
||||
let board = open_board(
|
||||
5,
|
||||
1,
|
||||
(4, 0),
|
||||
vec![obj1, obj2],
|
||||
&[
|
||||
("q", r#"fn init() {
|
||||
let infos = Board.tagged("enemy");
|
||||
log(infos.len().to_string());
|
||||
log(infos[0].id.to_string());
|
||||
}"#),
|
||||
("none", ""),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("q", r#"fn init() {
|
||||
let infos = Board.tagged("enemy");
|
||||
log(infos.len().to_string());
|
||||
log(infos[0].id.to_string());
|
||||
}"#),
|
||||
("none", ""),
|
||||
]));
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "1"); // exactly one match
|
||||
@@ -195,21 +187,21 @@ fn objects_with_tag_returns_matching_ids() {
|
||||
#[test]
|
||||
fn my_name_returns_name_or_empty_string() {
|
||||
// An object with a name set on its ObjectDef should see it via my_name().
|
||||
let mut board = board_with_object(
|
||||
let (mut board, scripts) = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
|
||||
let mut game = GameState::new(board);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["beacon"]);
|
||||
|
||||
// An unnamed object should get an empty string.
|
||||
let board2 = board_with_object(
|
||||
let (board2, scripts2) = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
let mut game2 = GameState::new(board2);
|
||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||
game2.run_init();
|
||||
assert_eq!(log_texts(&game2), vec![""]);
|
||||
}
|
||||
@@ -221,21 +213,15 @@ fn object_id_for_name_finds_by_name() {
|
||||
let obj1 = scripted_object(0, 0, "q");
|
||||
let mut obj2 = scripted_object(1, 0, "none");
|
||||
obj2.name = Some("target".to_string());
|
||||
let board = open_board(
|
||||
5,
|
||||
1,
|
||||
(4, 0),
|
||||
vec![obj1, obj2],
|
||||
&[
|
||||
("q", r#"fn init() {
|
||||
log(Board.named("target").id.to_string());
|
||||
let miss = Board.named("missing");
|
||||
log(if miss == () { "not_found" } else { miss.id.to_string() });
|
||||
}"#),
|
||||
("none", ""),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[
|
||||
("q", r#"fn init() {
|
||||
log(Board.named("target").id.to_string());
|
||||
let miss = Board.named("missing");
|
||||
log(if miss == () { "not_found" } else { miss.id.to_string() });
|
||||
}"#),
|
||||
("none", ""),
|
||||
]));
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "2"); // obj2 is id 2
|
||||
@@ -247,14 +233,8 @@ fn object_id_for_name_finds_by_name() {
|
||||
fn player_bump_fires_with_negative_one() {
|
||||
// The player walks into a solid scripted object; the object is bumped with -1
|
||||
// and the player does not move onto it.
|
||||
let board = open_board(
|
||||
3,
|
||||
1,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 0, "b")],
|
||||
&[("b", "fn bump(id) { log(`bumped by ${id}`); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]));
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
|
||||
@@ -268,12 +248,8 @@ fn scroll_opens_on_player_bump() {
|
||||
// A solid object's bump() calls scroll([text, [choice, display]]). After the
|
||||
// player bumps it and a tick resolves the queued action, active_scroll is set
|
||||
// with the correct ScrollLine variants.
|
||||
let board = open_board(
|
||||
3, 1, (0, 0),
|
||||
vec![scripted_object(1, 0, "s")],
|
||||
&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)]));
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
@@ -287,12 +263,8 @@ fn scroll_opens_on_player_bump() {
|
||||
|
||||
#[test]
|
||||
fn close_scroll_without_choice_clears_it() {
|
||||
let board = open_board(
|
||||
3, 1, (0, 0),
|
||||
vec![scripted_object(1, 0, "s")],
|
||||
&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]));
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
@@ -306,15 +278,11 @@ fn close_scroll_without_choice_clears_it() {
|
||||
fn close_scroll_with_choice_dispatches_send_to_source() {
|
||||
// Closing with a choice string fires send() on the object that opened the
|
||||
// scroll; fn eat() logging "eaten" confirms the dispatch arrived.
|
||||
let board = open_board(
|
||||
3, 1, (0, 0),
|
||||
vec![scripted_object(1, 0, "s")],
|
||||
&[("s", r#"
|
||||
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"
|
||||
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
"#)]));
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! World type: a named collection of boards loaded from a single `.toml` file.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use crate::board::Board;
|
||||
use crate::map_file::MapFile;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A named world containing one or more boards, loaded from a `.toml` file.
|
||||
///
|
||||
/// The file groups all boards under a `[boards]` table. Each board key maps
|
||||
/// to a full board definition (the same structure as a single-board map file,
|
||||
/// but nested under `[boards.<key>.*]`). [`World::start`] names the board the
|
||||
/// player enters first.
|
||||
///
|
||||
/// Scripts live at the world level ([`World::scripts`]) rather than on each
|
||||
/// board, so multiple boards can reference the same script name without
|
||||
/// duplicating source text.
|
||||
pub struct World {
|
||||
/// Human-readable name for this world, from the `[world]` header.
|
||||
pub name: String,
|
||||
/// Key of the starting board; matches a key in [`World::boards`].
|
||||
pub start: String,
|
||||
/// Named Rhai scripts shared across all boards: script name → source text.
|
||||
///
|
||||
/// Objects on any board reference a script by name via
|
||||
/// [`ObjectDef::script_name`](crate::object_def::ObjectDef). The script pool is
|
||||
/// passed to [`GameState::from_world`](crate::game::GameState::from_world) at startup.
|
||||
pub scripts: HashMap<String, String>,
|
||||
/// All boards in this world, keyed by their identifier string.
|
||||
///
|
||||
/// Every board is always present here as a shared `Rc<RefCell<Board>>` — no board
|
||||
/// is ever "removed" when active. [`GameState`](crate::game::GameState) holds a
|
||||
/// clone of the active board's `Rc` and borrows through it.
|
||||
pub boards: HashMap<String, Rc<RefCell<Board>>>,
|
||||
}
|
||||
|
||||
/// Serde target for the top-level `.toml` world file.
|
||||
#[derive(Deserialize)]
|
||||
struct WorldFile {
|
||||
world: WorldHeader,
|
||||
/// The `[scripts]` table: script name → Rhai source. Shared across all boards.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
scripts: HashMap<String, String>,
|
||||
/// Each value deserializes as a [`MapFile`], reusing the existing per-board
|
||||
/// serde types from `map_file.rs`.
|
||||
boards: HashMap<String, MapFile>,
|
||||
}
|
||||
|
||||
/// The `[world]` header section of a world file.
|
||||
#[derive(Deserialize)]
|
||||
struct WorldHeader {
|
||||
/// Human-readable name for this world.
|
||||
name: String,
|
||||
/// Key of the board to start on; must match a key in `[boards]`.
|
||||
start: String,
|
||||
}
|
||||
|
||||
/// Load a world from a `.toml` world file at `path`.
|
||||
///
|
||||
/// Returns an error if the file cannot be read or parsed, if any board fails
|
||||
/// the hard-error conversion (only a grid-dimension mismatch qualifies — all
|
||||
/// other problems are recorded on [`Board::load_errors`]), or if [`World::start`]
|
||||
/// does not match any board key in the file.
|
||||
pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let wf: WorldFile = toml::from_str(&text)?;
|
||||
|
||||
// Convert each MapFile into a Board. Grid-dimension mismatches are the only
|
||||
// hard errors; everything else is nonfatal and lands on Board::load_errors.
|
||||
let mut boards = HashMap::new();
|
||||
for (key, map_file) in wf.boards {
|
||||
let board = Board::try_from(map_file)
|
||||
.map_err(|e| format!("board '{}': {}", key, e))?;
|
||||
boards.insert(key, Rc::new(RefCell::new(board)));
|
||||
}
|
||||
|
||||
// Guard: the start key must actually exist in the boards map.
|
||||
if !boards.contains_key(&wf.world.start) {
|
||||
return Err(format!(
|
||||
"start board '{}' not found in world '{}'",
|
||||
wf.world.start, wf.world.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(World {
|
||||
name: wf.world.name,
|
||||
start: wf.world.start,
|
||||
scripts: wf.scripts,
|
||||
boards,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user