Moved tests
This commit is contained in:
@@ -187,634 +187,3 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
|
||||
bumped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::Player;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 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 {
|
||||
let mut object = ObjectDef::new(0, 0);
|
||||
object.script_name = object_script.map(str::to_string);
|
||||
Board {
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
||||
floor: vec![Archetype::Empty.default_glyph()],
|
||||
floor_spec: None,
|
||||
player: Player { x: 0, y: 0 },
|
||||
objects: BTreeMap::from([(1, object)]),
|
||||
next_object_id: 2,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ObjectDef` at `(x, y)` bound to the named script.
|
||||
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
let mut o = ObjectDef::new(x, y);
|
||||
o.script_name = Some(script.to_string());
|
||||
o
|
||||
}
|
||||
|
||||
/// Flattens each log line into a single string for easy assertions.
|
||||
fn log_texts(game: &GameState) -> Vec<String> {
|
||||
game.log
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.
|
||||
use color::Rgba8;
|
||||
use crate::glyph::Glyph;
|
||||
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 },
|
||||
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
|
||||
};
|
||||
board.floor = vec![floor_glyph; 4];
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.glyph_at(0, 0), floor_glyph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_pushes_single_crate() {
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0)); // player advanced onto crate's old cell
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate left this cell
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_blocked_by_wall_moves_nothing() {
|
||||
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);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate); // crate didn't move
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Wall); // wall didn't move
|
||||
}
|
||||
|
||||
#[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![], &[]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pushes_two_crates() {
|
||||
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);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_blocked_by_wall_moves_nothing() {
|
||||
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);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_pushes_pushable_solid_object() {
|
||||
// A solid, pushable object is shoved exactly like a crate.
|
||||
let mut obj = ObjectDef::new(1, 0); // solid by default
|
||||
obj.pushable = true;
|
||||
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();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); // object shoved east
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_pushes_crate_on_init() {
|
||||
// A scripted object moving east into a crate shoves it (move_object path).
|
||||
let mut board = open_board(
|
||||
4,
|
||||
1,
|
||||
(0, 0), // player behind the object, out of the push path
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); }")],
|
||||
);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); // object advanced
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty); // crate left (2,0)
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate); // crate pushed to (3,0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hcrate_pushes_east_but_not_north() {
|
||||
// Pushing east: the HCrate slides.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 3)); // blocked
|
||||
assert_eq!(b.get(0, 2).1, Archetype::HCrate); // didn't move
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcrate_pushes_north_but_not_east() {
|
||||
// Pushing north: the VCrate slides.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 2));
|
||||
assert_eq!(b.get(0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
|
||||
assert_eq!(b.get(1, 0).1, Archetype::VCrate); // didn't move
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let board = board_with_object(
|
||||
Some("greet"),
|
||||
&[("greet", r#"fn init() { log("hello"); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
// init must not fire during construction / deserialization.
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
let board = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
// No tick hook runs until tick() is called.
|
||||
game.run_init();
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
game.tick(Duration::from_millis(500));
|
||||
assert_eq!(game.log.len(), 1);
|
||||
// The dt argument reached the script as ~0.5 seconds.
|
||||
let logged: f64 = log_texts(&game)[0].parse().unwrap();
|
||||
assert!((logged - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_hooks_and_no_script_are_noops() {
|
||||
// Object with no script: nothing happens.
|
||||
let mut game = GameState::new(board_with_object(None, &[]));
|
||||
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(
|
||||
Some("e"),
|
||||
&[("e", "fn other() { log(\"x\"); }")],
|
||||
));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
}
|
||||
|
||||
#[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"), &[]));
|
||||
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( {")]));
|
||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_board_through_view() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(3, 1),
|
||||
vec![scripted_object(2, 1, "r")],
|
||||
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
// The view reported the player's x (3).
|
||||
assert_eq!(log_texts(&game), vec!["3"]);
|
||||
}
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
// East increments x by one; the object started at (2, 1).
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (3, 1));
|
||||
}
|
||||
|
||||
#[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);
|
||||
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);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_map_greeter_runs_init() {
|
||||
// End-to-end against the shipped example map: load it, run init, and
|
||||
// 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 board = crate::map_file::load(path).expect("load start.toml");
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert!(
|
||||
log_texts(&game)
|
||||
.iter()
|
||||
.any(|t| t.contains("hello from object")),
|
||||
"greeter init should log a greeting"
|
||||
);
|
||||
assert!(
|
||||
game.board().objects.values().any(|o| o.glyph.tile == 2),
|
||||
"greeter set_tile(2) should change its glyph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_routed_to_their_own_source_object() {
|
||||
// Two objects with different scripts move in opposite directions; each
|
||||
// must affect only itself (the per-call tag routes the command source).
|
||||
// Targets are kept apart so the impassable objects don't block each other.
|
||||
let board = open_board(
|
||||
5,
|
||||
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);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!(b.objects[&1].x, 1); // moved east from 0
|
||||
assert_eq!(b.objects[&2].x, 3); // moved west from 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
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), // player to the west, out of the object's path
|
||||
vec![scripted_object(1, 0, "m")],
|
||||
&[("m", "fn init() { move(East); move(East); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
|
||||
|
||||
// 200 ms of ticks: still inside the cooldown, no further movement.
|
||||
game.tick(Duration::from_millis(100));
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(game.board().objects[&1].x, 2);
|
||||
|
||||
// Crossing the 250 ms mark releases the queued second move.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(game.board().objects[&1].x, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_move_still_costs_cooldown() {
|
||||
// A move into a wall fails but still charges the 250 ms cooldown, so a second
|
||||
// queued move (into open space) is delayed just as a successful move would be.
|
||||
let mut board = open_board(
|
||||
3,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 1, "m")],
|
||||
&[("m", "fn init() { move(East); move(South); }")],
|
||||
);
|
||||
wall_at(&mut board, 2, 1); // blocks the eastward move
|
||||
let mut game = GameState::new(board);
|
||||
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)
|
||||
);
|
||||
|
||||
// The blocked move charged the cooldown, so South is still pending here.
|
||||
game.tick(Duration::from_millis(100));
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(
|
||||
(game.board().objects[&1].x, game.board().objects[&1].y),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// Past 250 ms the queued South move resolves.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(
|
||||
(game.board().objects[&1].x, game.board().objects[&1].y),
|
||||
(1, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(300));
|
||||
assert_eq!(game.board().objects[&1].x, 1); // never moved
|
||||
}
|
||||
|
||||
#[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); } }",
|
||||
)],
|
||||
);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
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);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_sees_earlier_objects_pending_move() {
|
||||
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
|
||||
// toward that same cell and must see the pending move.
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![
|
||||
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);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); // mover advanced
|
||||
assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
|
||||
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
|
||||
let board = open_board(
|
||||
3,
|
||||
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);
|
||||
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.
|
||||
game.tick(Duration::from_millis(300));
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); // obj0 won the cell
|
||||
assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked
|
||||
}
|
||||
let logs = log_texts(&game);
|
||||
assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1
|
||||
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
|
||||
// bump's log is emitted into the object's queue; a tick flushes it.
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); // object took player's cell
|
||||
assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east
|
||||
}
|
||||
|
||||
// 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); }")],
|
||||
);
|
||||
wall_at(&mut board, 3, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); // object blocked
|
||||
assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,7 @@ mod archetype;
|
||||
mod object_def;
|
||||
mod board;
|
||||
|
||||
pub use board::Board;
|
||||
pub use board::Board;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -702,402 +702,3 @@ pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>
|
||||
std::fs::write(path, toml_str)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::archetype::Archetype;
|
||||
|
||||
/// Parses `toml` and converts it to a [`Board`], panicking on any hard error.
|
||||
/// (Object/placement validation failures are non-fatal `eprintln`s, so the
|
||||
/// board still loads — these tests assert on the resulting objects/cells.)
|
||||
fn load_board(toml: &str) -> Board {
|
||||
let mf: MapFile = toml::from_str(toml).expect("parse toml");
|
||||
Board::try_from(mf).expect("convert to board")
|
||||
}
|
||||
|
||||
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
|
||||
/// `[[objects]]` TOML appended. Keeps placement tests compact.
|
||||
fn map_3x1(grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 1
|
||||
player_start = [2, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A standard `[[objects]]` block placed by palette char `ch`.
|
||||
fn obj_palette(ch: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_placement_puts_object_on_empty_floor() {
|
||||
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
|
||||
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
// The cell under the object is canonical Empty floor.
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_absent_from_grid_drops_object() {
|
||||
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
assert!(!board.is_valid()); // a dropped object is a recoverable error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_appearing_twice_uses_first() {
|
||||
// `G` appears at (0,0) and (2,0): the object lands at the first, and the
|
||||
// map is flagged invalid (an extra appearance was reported).
|
||||
let board = load_board(&map_3x1("G.G", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_reused_by_two_objects_keeps_first() {
|
||||
// Two objects claim `G`: the first keeps it, the later one is dropped.
|
||||
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
|
||||
let board = load_board(&map_3x1("G..", &two));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_colliding_with_palette_key_drops_object() {
|
||||
// `#` is a terrain palette key, so it can't be an object placeholder.
|
||||
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
|
||||
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
|
||||
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("#..", solid));
|
||||
assert!(board.objects.is_empty());
|
||||
|
||||
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
|
||||
let nonsolid = format!("{solid}solid = false\n");
|
||||
let board = load_board(&map_3x1("#..", &nonsolid));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_solid_object_on_a_cell_is_dropped() {
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
/// A 1-row map of the given `width` with a raw `player_start` TOML value
|
||||
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
|
||||
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = 1
|
||||
player_start = {start}
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_coords_place_the_player() {
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_places_on_empty_floor() {
|
||||
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_solid_terrain_silently() {
|
||||
// Player coords land on a wall: the wall is cleared, no error reported.
|
||||
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_against_a_solid_object_silently() {
|
||||
// A solid object on the player's cell is dropped, silently (player wins).
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_is_reserved_from_objects() {
|
||||
// An object trying to use '@' is dropped; the player is still placed.
|
||||
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_appearing_twice_uses_first() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_missing_falls_back_to_origin() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "...", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_fallback_to_origin_clears_solid_terrain() {
|
||||
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
|
||||
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
|
||||
// holds (the player is itself solid). The fallback is still reported.
|
||||
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty); // wall cleared under the player
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
|
||||
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
|
||||
let content = grid_rows.join("\n");
|
||||
// Use r##"..."## so the "# in color strings does not close the raw string.
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = {height}
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{content}
|
||||
"""
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_count_returns_error() {
|
||||
// height = 3 but only 2 rows in the grid
|
||||
let toml = minimal_toml(3, 3, &["...", "..."]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
// Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug.
|
||||
let msg = result.err().unwrap();
|
||||
assert!(
|
||||
msg.contains("2 rows"),
|
||||
"expected row count in error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("height = 3"),
|
||||
"expected declared height in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_width_returns_error() {
|
||||
// width = 4 but second row is only 3 characters
|
||||
let toml = minimal_toml(4, 2, &["....", "..."]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap();
|
||||
assert!(
|
||||
msg.contains("3 characters"),
|
||||
"expected col count in error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("width = 4"),
|
||||
"expected declared width in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_archetype_in_palette_produces_error_block() {
|
||||
// "object" is no longer a valid palette archetype; it should produce
|
||||
// ErrorBlock. Player is placed away from the O cell so it isn't cleared.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
player_start = [1, 0]
|
||||
|
||||
[palette]
|
||||
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
O.
|
||||
"""
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
let (_, arch) = board.get(0, 0);
|
||||
assert_eq!(*arch, Archetype::ErrorBlock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_glyph_round_trips_through_toml() {
|
||||
// Objects must survive a save→load cycle with their glyph intact.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 3
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
...
|
||||
...
|
||||
...
|
||||
"""
|
||||
|
||||
[[objects]]
|
||||
x = 1
|
||||
y = 1
|
||||
tile = 64
|
||||
fg = "#00FFFF"
|
||||
bg = "#000000"
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
let obj = &board.objects[&1];
|
||||
assert_eq!(obj.x, 1);
|
||||
assert_eq!(obj.y, 1);
|
||||
assert_eq!(obj.glyph.tile, 64);
|
||||
assert_eq!(
|
||||
obj.glyph.fg,
|
||||
Rgba8 {
|
||||
r: 0x00,
|
||||
g: 0xFF,
|
||||
b: 0xFF,
|
||||
a: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
obj.glyph.bg,
|
||||
Rgba8 {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255
|
||||
}
|
||||
);
|
||||
|
||||
// Save back to TOML and reload; glyph must survive.
|
||||
let map_file = MapFile::from(&board);
|
||||
let toml_out = toml::to_string_pretty(&map_file).unwrap();
|
||||
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
|
||||
let board2 = Board::try_from(mf2).unwrap();
|
||||
let obj2 = &board2.objects[&1];
|
||||
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
|
||||
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
|
||||
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_spec_round_trips_through_toml() {
|
||||
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
||||
// save→load with its `[floor]` declaration intact, and the reloaded board's
|
||||
// computed floor must place the fixed glyph back at its declared cell.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 2
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
..
|
||||
..
|
||||
"""
|
||||
|
||||
[floor]
|
||||
content = """
|
||||
g.
|
||||
.g
|
||||
"""
|
||||
|
||||
[floor.palette]
|
||||
"g" = "grass"
|
||||
"." = { tile = "#", fg = "#010203", bg = "#040506" }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.floor_spec.is_some());
|
||||
let fixed = Glyph {
|
||||
tile: '#' as u32,
|
||||
fg: parse_color("#010203"),
|
||||
bg: parse_color("#040506"),
|
||||
};
|
||||
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
|
||||
assert_eq!(board.glyph_at(1, 0), fixed);
|
||||
|
||||
// Save back to TOML and reload; the floor declaration must be preserved.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let board2 = load_board(&toml_out);
|
||||
assert!(board2.floor_spec.is_some());
|
||||
assert_eq!(board2.glyph_at(1, 0), fixed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::time::Duration;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use crate::game::GameState;
|
||||
use crate::script::Direction;
|
||||
use super::{scripted_object, log_texts};
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
// East increments x by one; the object started at (2, 1).
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (3, 1));
|
||||
}
|
||||
|
||||
#[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);
|
||||
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);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
|
||||
#[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); }")],
|
||||
);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
|
||||
assert_eq!((b.player.x, b.player.y), (3, 0));
|
||||
}
|
||||
|
||||
// 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); }")],
|
||||
);
|
||||
wall_at(&mut board, 3, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
|
||||
assert_eq!((b.player.x, b.player.y), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
|
||||
|
||||
// 200 ms of ticks: still inside the cooldown, no further movement.
|
||||
game.tick(Duration::from_millis(100));
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(game.board().objects[&1].x, 2);
|
||||
|
||||
// Crossing the 250 ms mark releases the queued second move.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(game.board().objects[&1].x, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_move_still_costs_cooldown() {
|
||||
// A move into a wall fails but still charges the 250 ms cooldown, so a second
|
||||
// queued move (into open space) is delayed just as a successful move would be.
|
||||
let mut board = open_board(
|
||||
3,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(1, 1, "m")],
|
||||
&[("m", "fn init() { move(East); move(South); }")],
|
||||
);
|
||||
wall_at(&mut board, 2, 1);
|
||||
let mut game = GameState::new(board);
|
||||
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));
|
||||
|
||||
// The blocked move charged the cooldown, so South is still pending here.
|
||||
game.tick(Duration::from_millis(100));
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
|
||||
|
||||
// Past 250 ms the queued South move resolves.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
#[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);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(300));
|
||||
assert_eq!(game.board().objects[&1].x, 1); // never moved
|
||||
}
|
||||
|
||||
#[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); } }",
|
||||
)],
|
||||
);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::new(board);
|
||||
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);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_sees_earlier_objects_pending_move() {
|
||||
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
|
||||
// toward that same cell and must see the pending move.
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![
|
||||
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);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
|
||||
assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::time::Duration;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use super::{scripted_object, log_texts};
|
||||
|
||||
#[test]
|
||||
fn collision_priority_resolves_in_array_order_and_bumps() {
|
||||
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
|
||||
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
|
||||
let board = open_board(
|
||||
3,
|
||||
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);
|
||||
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.
|
||||
game.tick(Duration::from_millis(300));
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); // obj0 won the cell
|
||||
assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked
|
||||
}
|
||||
let logs = log_texts(&game);
|
||||
assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1
|
||||
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::map_file::MapFile;
|
||||
use super::minimal_toml;
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_count_returns_error() {
|
||||
// height = 3 but only 2 rows in the grid
|
||||
let toml = minimal_toml(3, 3, &["...", "..."]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap();
|
||||
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
|
||||
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_wrong_row_width_returns_error() {
|
||||
// width = 4 but second row is only 3 characters
|
||||
let toml = minimal_toml(4, 2, &["....", "..."]);
|
||||
let mf: MapFile = toml::from_str(&toml).unwrap();
|
||||
let result = Board::try_from(mf);
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap();
|
||||
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
|
||||
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_archetype_in_palette_produces_error_block() {
|
||||
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
|
||||
// Player is placed away from the O cell so it isn't cleared.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 1
|
||||
player_start = [1, 0]
|
||||
|
||||
[palette]
|
||||
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
O.
|
||||
"""
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
mod grid_errors;
|
||||
mod object_placement;
|
||||
mod player_placement;
|
||||
mod round_trip;
|
||||
|
||||
use crate::board::Board;
|
||||
use crate::map_file::MapFile;
|
||||
|
||||
fn load_board(toml: &str) -> Board {
|
||||
let mf: MapFile = toml::from_str(toml).expect("parse toml");
|
||||
Board::try_from(mf).expect("convert to board")
|
||||
}
|
||||
|
||||
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
|
||||
/// `[[objects]]` TOML appended. Keeps placement tests compact.
|
||||
fn map_3x1(grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 1
|
||||
player_start = [2, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A standard `[[objects]]` block placed by palette char `ch`.
|
||||
fn obj_palette(ch: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
|
||||
)
|
||||
}
|
||||
|
||||
/// A 1-row map of the given `width` with a raw `player_start` TOML value
|
||||
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
|
||||
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = 1
|
||||
player_start = {start}
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{grid}
|
||||
"""
|
||||
{objects}
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
|
||||
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
|
||||
let content = grid_rows.join("\n");
|
||||
format!(
|
||||
r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = {width}
|
||||
height = {height}
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
{content}
|
||||
"""
|
||||
"##
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::archetype::Archetype;
|
||||
use super::{load_board, map_3x1, obj_palette};
|
||||
|
||||
#[test]
|
||||
fn palette_placement_puts_object_on_empty_floor() {
|
||||
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
|
||||
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_absent_from_grid_drops_object() {
|
||||
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_appearing_twice_uses_first() {
|
||||
// `G` appears at (0,0) and (2,0): the object lands at the first, and the
|
||||
// map is flagged invalid (an extra appearance was reported).
|
||||
let board = load_board(&map_3x1("G.G", &obj_palette("G", "")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_reused_by_two_objects_keeps_first() {
|
||||
// Two objects claim `G`: the first keeps it, the later one is dropped.
|
||||
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
|
||||
let board = load_board(&map_3x1("G..", &two));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_char_colliding_with_palette_key_drops_object() {
|
||||
// `#` is a terrain palette key, so it can't be an object placeholder.
|
||||
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
|
||||
assert!(board.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
|
||||
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
|
||||
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("#..", solid));
|
||||
assert!(board.objects.is_empty());
|
||||
|
||||
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
|
||||
let nonsolid = format!("{solid}solid = false\n");
|
||||
let board = load_board(&map_3x1("#..", &nonsolid));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_solid_object_on_a_cell_is_dropped() {
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
assert!(!board.is_valid());
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use crate::archetype::Archetype;
|
||||
use super::{load_board, player_map};
|
||||
|
||||
#[test]
|
||||
fn player_coords_place_the_player() {
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_places_on_empty_floor() {
|
||||
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_solid_terrain_silently() {
|
||||
// Player coords land on a wall: the wall is cleared, no error reported.
|
||||
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_wins_against_a_solid_object_silently() {
|
||||
// A solid object on the player's cell is dropped, silently (player wins).
|
||||
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_is_reserved_from_objects() {
|
||||
// An object trying to use '@' is dropped; the player is still placed.
|
||||
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
|
||||
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(b.objects.is_empty());
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_appearing_twice_uses_first() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_char_missing_falls_back_to_origin() {
|
||||
let b = load_board(&player_map(3, "\"@\"", "...", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_fallback_to_origin_clears_solid_terrain() {
|
||||
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
|
||||
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
|
||||
// holds. The fallback is still reported.
|
||||
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(0, 0).1, Archetype::Empty);
|
||||
assert!(!b.is_valid());
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use color::Rgba8;
|
||||
use crate::board::Board;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::map_file::{MapFile, parse_color};
|
||||
use super::load_board;
|
||||
|
||||
#[test]
|
||||
fn object_glyph_round_trips_through_toml() {
|
||||
// Objects must survive a save→load cycle with their glyph intact.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 3
|
||||
height = 3
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
...
|
||||
...
|
||||
...
|
||||
"""
|
||||
|
||||
[[objects]]
|
||||
x = 1
|
||||
y = 1
|
||||
tile = 64
|
||||
fg = "#00FFFF"
|
||||
bg = "#000000"
|
||||
"##;
|
||||
let mf: MapFile = toml::from_str(toml).unwrap();
|
||||
let board = Board::try_from(mf).unwrap();
|
||||
assert_eq!(board.objects.len(), 1);
|
||||
let obj = &board.objects[&1];
|
||||
assert_eq!(obj.x, 1);
|
||||
assert_eq!(obj.y, 1);
|
||||
assert_eq!(obj.glyph.tile, 64);
|
||||
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
|
||||
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
|
||||
|
||||
// Save back to TOML and reload; glyph must survive.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
|
||||
let board2 = Board::try_from(mf2).unwrap();
|
||||
let obj2 = &board2.objects[&1];
|
||||
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
|
||||
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
|
||||
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_spec_round_trips_through_toml() {
|
||||
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
||||
// save→load with its `[floor]` declaration intact, and the reloaded board's
|
||||
// computed floor must place the fixed glyph back at its declared cell.
|
||||
let toml = r##"
|
||||
[map]
|
||||
name = "Test"
|
||||
width = 2
|
||||
height = 2
|
||||
player_start = [0, 0]
|
||||
|
||||
[palette]
|
||||
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||
|
||||
[grid]
|
||||
content = """
|
||||
..
|
||||
..
|
||||
"""
|
||||
|
||||
[floor]
|
||||
content = """
|
||||
g.
|
||||
.g
|
||||
"""
|
||||
|
||||
[floor.palette]
|
||||
"g" = "grass"
|
||||
"." = { tile = "#", fg = "#010203", bg = "#040506" }
|
||||
"##;
|
||||
let board = load_board(toml);
|
||||
assert!(board.floor_spec.is_some());
|
||||
let fixed = Glyph {
|
||||
tile: '#' as u32,
|
||||
fg: parse_color("#010203"),
|
||||
bg: parse_color("#040506"),
|
||||
};
|
||||
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
|
||||
assert_eq!(board.glyph_at(1, 0), fixed);
|
||||
|
||||
// Save back to TOML and reload; the floor declaration must be preserved.
|
||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||
let board2 = load_board(&toml_out);
|
||||
assert!(board2.floor_spec.is_some());
|
||||
assert_eq!(board2.glyph_at(1, 0), fixed);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
mod actions;
|
||||
mod collision;
|
||||
mod map_file;
|
||||
mod movement;
|
||||
mod scripting;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
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 {
|
||||
let mut object = ObjectDef::new(0, 0);
|
||||
object.script_name = object_script.map(str::to_string);
|
||||
Board {
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
||||
floor: vec![Archetype::Empty.default_glyph()],
|
||||
floor_spec: None,
|
||||
player: Player { x: 0, y: 0 },
|
||||
objects: BTreeMap::from([(1, object)]),
|
||||
next_object_id: 2,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
|
||||
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
let mut o = ObjectDef::new(x, y);
|
||||
o.script_name = Some(script.to_string());
|
||||
o
|
||||
}
|
||||
|
||||
/// Flattens each log line into a single string for easy assertions.
|
||||
fn log_texts(game: &GameState) -> Vec<String> {
|
||||
game.log
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use color::Rgba8;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::script::Direction;
|
||||
|
||||
#[test]
|
||||
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 floor_glyph = Glyph {
|
||||
tile: ',' as u32,
|
||||
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
|
||||
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
|
||||
};
|
||||
board.floor = vec![floor_glyph; 4];
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.glyph_at(0, 0), floor_glyph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_pushes_single_crate() {
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_blocked_by_wall_moves_nothing() {
|
||||
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);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Wall);
|
||||
}
|
||||
|
||||
#[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![], &[]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pushes_two_crates() {
|
||||
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);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_blocked_by_wall_moves_nothing() {
|
||||
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);
|
||||
let mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Crate);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 mut game = GameState::new(board);
|
||||
game.try_move(Direction::East);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hcrate_pushes_east_but_not_north() {
|
||||
// Pushing east: the HCrate slides.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (1, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 3));
|
||||
assert_eq!(b.get(0, 2).1, Archetype::HCrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcrate_pushes_north_but_not_east() {
|
||||
// Pushing north: the VCrate slides.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 2));
|
||||
assert_eq!(b.get(0, 2).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
|
||||
drop(b);
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
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);
|
||||
let b = game.board();
|
||||
assert_eq!((b.player.x, b.player.y), (0, 0));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::VCrate);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::time::Duration;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use crate::script::Direction;
|
||||
use super::{board_with_object, scripted_object, log_texts};
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let board = board_with_object(
|
||||
Some("greet"),
|
||||
&[("greet", r#"fn init() { log("hello"); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
// init must not fire during construction / deserialization.
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
let board = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
game.tick(Duration::from_millis(500));
|
||||
assert_eq!(game.log.len(), 1);
|
||||
let logged: f64 = log_texts(&game)[0].parse().unwrap();
|
||||
assert!((logged - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_hooks_and_no_script_are_noops() {
|
||||
// Object with no script: nothing happens.
|
||||
let mut game = GameState::new(board_with_object(None, &[]));
|
||||
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(
|
||||
Some("e"),
|
||||
&[("e", "fn other() { log(\"x\"); }")],
|
||||
));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
}
|
||||
|
||||
#[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"), &[]));
|
||||
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( {")]));
|
||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_board_through_view() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(3, 1),
|
||||
vec![scripted_object(2, 1, "r")],
|
||||
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_routed_to_their_own_source_object() {
|
||||
// Two objects with different scripts move in opposite directions; each
|
||||
// must affect only itself (the per-call tag routes the command source).
|
||||
let board = open_board(
|
||||
5,
|
||||
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);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!(b.objects[&1].x, 1); // moved east from 0
|
||||
assert_eq!(b.objects[&2].x, 3); // moved west from 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_map_greeter_runs_init() {
|
||||
// End-to-end against the shipped example map: load it, run init, and
|
||||
// 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 board = crate::map_file::load(path).expect("load start.toml");
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert!(
|
||||
log_texts(&game).iter().any(|t| t.contains("hello from object")),
|
||||
"greeter init should log a greeting"
|
||||
);
|
||||
assert!(
|
||||
game.board().objects.values().any(|o| o.glyph.tile == 2),
|
||||
"greeter set_tile(2) should change its glyph"
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure try_move from the player side also triggers scripted bump
|
||||
#[test]
|
||||
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);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
|
||||
// bump's log is emitted into the object's queue; a tick flushes it.
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
|
||||
}
|
||||
Reference in New Issue
Block a user