fixing tests
This commit is contained in:
+11
-5
@@ -766,14 +766,20 @@ pub(crate) mod tests {
|
||||
id
|
||||
}
|
||||
|
||||
/// Stamps a solid object at `(x, y)` with **no script attached**, returning its id.
|
||||
/// Stamps an object at `(x, y)` with **no script attached**, returning its id.
|
||||
///
|
||||
/// For exercising the "an object without a script is inert" path; everything
|
||||
/// else should use [`object_at`].
|
||||
pub(crate) fn plain_object_at(board: &mut Board, x: usize, y: usize) -> ObjectId {
|
||||
/// For the "an object without a script is inert" path, and for plain physical
|
||||
/// props (a pushable block with no behavior of its own). Everything scripted
|
||||
/// should use [`object_at`].
|
||||
pub(crate) fn plain_object_at(
|
||||
board: &mut Board,
|
||||
x: usize,
|
||||
y: usize,
|
||||
enter: EnterResponse,
|
||||
) -> ObjectId {
|
||||
let tile = TileSpec::Object {
|
||||
script: None,
|
||||
enter: EnterResponse::Block,
|
||||
enter,
|
||||
glyph: ObjectDef::default_glyph(),
|
||||
optics: Optics { opaque: true, glow: 0 },
|
||||
name: None,
|
||||
|
||||
@@ -26,11 +26,11 @@ pub struct Portal {
|
||||
/// Name of the arrival portal on the target board.
|
||||
pub target_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
glyph: Option<Glyph>
|
||||
pub glyph: Option<Glyph>
|
||||
}
|
||||
|
||||
impl Portal {
|
||||
pub fn location(&self) -> (usize, usize) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-96
@@ -1,187 +1,208 @@
|
||||
use super::{log_texts, scripted_object, scripts_from};
|
||||
use crate::builtin::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use super::{log_texts, scripts_from};
|
||||
use crate::board::tests::{crate_at, is_builtin, object_at, open_board, wall_at};
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::tile::EnterResponse;
|
||||
use crate::utils::ObjectId;
|
||||
use std::time::Duration;
|
||||
use crate::Builtin;
|
||||
|
||||
/// The cell object `id` currently occupies.
|
||||
fn loc(game: &GameState, id: ObjectId) -> (usize, usize) {
|
||||
game.board()
|
||||
.get_hookable(id)
|
||||
.expect("object still on the board")
|
||||
.location()
|
||||
}
|
||||
|
||||
/// Object `id`'s current glyph.
|
||||
fn glyph(game: &GameState, id: ObjectId) -> Glyph {
|
||||
game.board()
|
||||
.get_hookable(id)
|
||||
.expect("object still on the board")
|
||||
.glyph()
|
||||
}
|
||||
|
||||
/// Builds a game with one `Block` object at `(x, y)` running `src`, plus the player
|
||||
/// parked at `player`, and runs `init()`.
|
||||
///
|
||||
/// The mover is `EnterResponse::Block` — an ordinary solid that blocks others and
|
||||
/// cannot be shoved, which is what a self-propelled scripted object should be. Its
|
||||
/// `enter_response` describes how it answers *others* entering its cell and should
|
||||
/// not constrain its own `move()`.
|
||||
fn game_with_mover(
|
||||
w: usize,
|
||||
h: usize,
|
||||
player: (usize, usize),
|
||||
at: (usize, usize),
|
||||
src: &str,
|
||||
) -> (GameState, ObjectId) {
|
||||
let mut board = open_board(w, h, player);
|
||||
let id = object_at(&mut board, at.0, at.1, "m", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("m", src)]));
|
||||
game.run_init();
|
||||
(game, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_relocates_the_source_object() {
|
||||
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(m) { move(East); }")]));
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { move(East); }");
|
||||
// East increments x by one; the object started at (2, 1).
|
||||
assert_eq!((b.objects[&1].x, b.objects[&1].y), (3, 1));
|
||||
assert_eq!(loc(&game, id), (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")]);
|
||||
let (game, id) = game_with_mover(5, 3, (0, 0), (0, 1), "fn init(me) { move(West); }");
|
||||
assert_eq!(loc(&game, id), (0, 1));
|
||||
|
||||
// Object facing a wall: blocked, also ignored.
|
||||
let mut board = open_board(5, 3, (0, 0));
|
||||
let id = object_at(&mut board, 1, 1, "m", EnterResponse::Block);
|
||||
wall_at(&mut board, 2, 1);
|
||||
let mut game =
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(West); }")]));
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 0);
|
||||
assert_eq!(loc(&game, id), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tile_command_changes_the_source_glyph() {
|
||||
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(m) { set_tile(7); }")]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(7); }");
|
||||
assert_eq!(glyph(&game, id).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")]);
|
||||
// A scripted object moving east into a crate shoves it (the step_object path).
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
let id = object_at(&mut board, 1, 0, "m", EnterResponse::Block);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let mut game =
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
|
||||
game.run_init();
|
||||
|
||||
assert_eq!(loc(&game, id), (2, 0));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert!(is_builtin(&b, 3, 0, "crate")); // crate shoved one east
|
||||
assert!(b.get(1, 0).is_none()); // the object's old cell is vacated
|
||||
}
|
||||
|
||||
#[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")]);
|
||||
let mut game =
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
|
||||
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));
|
||||
}
|
||||
let (game, id) = game_with_mover(5, 1, (2, 0), (1, 0), "fn init(me) { move(East); }");
|
||||
assert_eq!(loc(&game, id), (2, 0));
|
||||
assert_eq!(game.board().player_pos(), (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")]);
|
||||
let mut board = open_board(4, 1, (2, 0));
|
||||
let id = object_at(&mut board, 1, 0, "m", EnterResponse::Block);
|
||||
wall_at(&mut board, 3, 0);
|
||||
let mut game =
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
|
||||
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
|
||||
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));
|
||||
|
||||
assert_eq!(loc(&game, id), (1, 0));
|
||||
assert_eq!(game.board().player_pos(), (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")]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("m", "fn init(m) { move(East); move(East); }")]),
|
||||
// wait the full 250 ms cooldown that move() appends behind it.
|
||||
let (mut game, id) = game_with_mover(
|
||||
5,
|
||||
1,
|
||||
(0, 0),
|
||||
(1, 0),
|
||||
"fn init(me) { move(East); move(East); }",
|
||||
);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
|
||||
assert_eq!(loc(&game, id).0, 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);
|
||||
assert_eq!(loc(&game, id).0, 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);
|
||||
assert_eq!(loc(&game, id).0, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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")]);
|
||||
let mut board = open_board(3, 3, (0, 0));
|
||||
let id = object_at(&mut board, 1, 1, "m", EnterResponse::Block);
|
||||
wall_at(&mut board, 2, 1);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("m", "fn init(m) { move(East); move(South); }")]),
|
||||
scripts_from(&[("m", "fn init(me) { 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)
|
||||
);
|
||||
assert_eq!(loc(&game, id), (1, 1));
|
||||
|
||||
// The Delay(250 ms) appended by move(East) is still live, so South is pending.
|
||||
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)
|
||||
);
|
||||
assert_eq!(loc(&game, id), (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)
|
||||
);
|
||||
assert_eq!(loc(&game, id), (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")]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[(
|
||||
"q",
|
||||
"fn init(m) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }",
|
||||
)]),
|
||||
// Two zero-cost actions are queued before the length is read; log() bypasses the
|
||||
// queue so it reports the length as of that moment, then both set_tiles drain.
|
||||
let (game, id) = game_with_mover(
|
||||
3,
|
||||
1,
|
||||
(0, 0),
|
||||
(1, 0),
|
||||
"fn init(me) { set_tile(5); set_tile(6); log(`len=${me.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
|
||||
assert_eq!(glyph(&game, id).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")]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]),
|
||||
let (mut game, id) = game_with_mover(
|
||||
5,
|
||||
1,
|
||||
(0, 0),
|
||||
(1, 0),
|
||||
"fn init(me) { move(East); move(East); me.queue.clear(); }",
|
||||
);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(300));
|
||||
assert_eq!(game.board().objects[&1].x, 1); // never moved
|
||||
assert_eq!(loc(&game, id), (1, 0)); // never moved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_reports_solid_and_clear() {
|
||||
let src = "fn init(me) { if me.blocked(East) { set_tile(9); } else { set_tile(7); } }";
|
||||
|
||||
// Solid ahead (a wall): blocked() is true.
|
||||
let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
|
||||
wall_at(&mut board, 2, 0);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[(
|
||||
"b",
|
||||
"fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]),
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 9);
|
||||
assert_eq!(glyph(&game, id).tile, 9);
|
||||
|
||||
// Open ahead, nothing pending: blocked() is false.
|
||||
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(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||
)]),
|
||||
);
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[&1].glyph.tile, 7);
|
||||
assert_eq!(glyph(&game, id).tile, 7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use super::{log_texts, scripted_object, scripts_from};
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use std::time::Duration;
|
||||
|
||||
#[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")],
|
||||
);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
(
|
||||
"e",
|
||||
"fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }",
|
||||
),
|
||||
(
|
||||
"w",
|
||||
"fn init(m) { move(West); } fn bump(m,dir) { log(`o1 from ${dir}`); }",
|
||||
),
|
||||
]),
|
||||
);
|
||||
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);
|
||||
// obj1 moved West into obj0, so the bump arrives from the East side of obj0.
|
||||
assert!(logs.iter().any(|t| t == "o0 from East")); // obj0 bumped by obj1
|
||||
assert!(!logs.iter().any(|t| t.starts_with("o1 from"))); // obj1 not bumped
|
||||
}
|
||||
@@ -1,63 +1,51 @@
|
||||
use crate::builtin::Archetype;
|
||||
//! Board-to-board transitions via [`GameState::enter_board`] and portal stepping.
|
||||
//!
|
||||
//! Portals live in `Board::portals`, parallel to the grid rather than in it — the
|
||||
//! player shares a cell with one — so `try_move` checks `portal_at` only after the
|
||||
//! player has actually relocated.
|
||||
|
||||
use crate::board::Board;
|
||||
use crate::floor::Floor;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::utils::{Direction, PlayerPos};
|
||||
use crate::portal::Portal;
|
||||
use crate::utils::Direction;
|
||||
use crate::world::World;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use crate::portal::Portal;
|
||||
|
||||
/// Builds a 3×3 board with the player at `(px, py)` and the given portals.
|
||||
fn make_board(px: i64, py: i64, portals: Vec<Portal>) -> Board {
|
||||
Board {
|
||||
name: "test".into(),
|
||||
width: 3,
|
||||
height: 3,
|
||||
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
|
||||
floor: Floor::Blank,
|
||||
decorations: Vec::new(),
|
||||
sensors: Vec::new(),
|
||||
player: PlayerPos { x: px, y: py },
|
||||
objects: BTreeMap::new(),
|
||||
next_object_id: 1,
|
||||
portals,
|
||||
board_script_name: None,
|
||||
dark: false,
|
||||
load_errors: Vec::new(),
|
||||
registry: HashMap::new(),
|
||||
/// Builds a 3×3 board with the player at `player` and the given portals.
|
||||
fn make_board(player: (usize, usize), portals: Vec<Portal>) -> Board {
|
||||
let mut board = open_board(3, 3, player);
|
||||
board.portals = portals;
|
||||
board
|
||||
}
|
||||
|
||||
/// A glyphless portal at `(x, y)` pointing at `target_name` on `target_board`.
|
||||
fn portal(
|
||||
x: usize,
|
||||
y: usize,
|
||||
name: &str,
|
||||
target_board: &str,
|
||||
target_name: &str,
|
||||
) -> Portal {
|
||||
Portal {
|
||||
x,
|
||||
y,
|
||||
name: name.to_string(),
|
||||
target_board: target_board.to_string(),
|
||||
target_name: target_name.to_string(),
|
||||
glyph: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-board world used by all tests in this module.
|
||||
/// Two-board world used by all tests in this module, with b1's player at `b1_player`.
|
||||
///
|
||||
/// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
|
||||
/// - `"b1"`: portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
|
||||
/// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"`
|
||||
fn two_board_world() -> World {
|
||||
let b1 = make_board(
|
||||
0,
|
||||
0,
|
||||
vec![Portal {
|
||||
name: "to_b2".into(),
|
||||
x: 2,
|
||||
y: 0,
|
||||
target_board: "b2".into(),
|
||||
target_name: "from_b1".into(),
|
||||
}],
|
||||
);
|
||||
let b2 = make_board(
|
||||
0,
|
||||
0,
|
||||
vec![Portal {
|
||||
name: "from_b1".into(),
|
||||
x: 1,
|
||||
y: 1,
|
||||
target_board: "b1".into(),
|
||||
target_name: "to_b2".into(),
|
||||
}],
|
||||
);
|
||||
fn two_board_world_with_player(b1_player: (usize, usize)) -> World {
|
||||
let b1 = make_board(b1_player, vec![portal(2, 0, "to_b2", "b2", "from_b1")]);
|
||||
let b2 = make_board((0, 0), vec![portal(1, 1, "from_b1", "b1", "to_b2")]);
|
||||
World {
|
||||
name: "test".into(),
|
||||
start: "b1".into(),
|
||||
@@ -70,6 +58,11 @@ fn two_board_world() -> World {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two-board world with b1's player at the origin.
|
||||
fn two_board_world() -> World {
|
||||
two_board_world_with_player((0, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_board_switches_board() {
|
||||
let mut game = GameState::from_world(two_board_world());
|
||||
@@ -81,10 +74,25 @@ fn enter_board_switches_board() {
|
||||
fn enter_board_places_player_at_arrival_portal() {
|
||||
let mut game = GameState::from_world(two_board_world());
|
||||
game.enter_board("b2", "from_b1");
|
||||
let board = game.board();
|
||||
// Arrival portal "from_b1" is at (1, 1) on b2.
|
||||
assert_eq!(board.player.x, 1);
|
||||
assert_eq!(board.player.y, 1);
|
||||
assert_eq!(game.board().player_pos(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_a_board_leaves_exactly_one_player_on_it() {
|
||||
// b2 is authored with a player at (0,0); arriving through the portal must
|
||||
// *relocate* that player rather than stamping a second one, or the board ends
|
||||
// up with two Tile::Player cells and player_pos() reports whichever comes
|
||||
// first in row-major order.
|
||||
let mut game = GameState::from_world(two_board_world());
|
||||
game.enter_board("b2", "from_b1");
|
||||
|
||||
let b = game.board();
|
||||
let players = (0..b.height)
|
||||
.flat_map(|y| (0..b.width).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| b.get(x, y).as_ref().is_some_and(|t| t.player()))
|
||||
.count();
|
||||
assert_eq!(players, 1, "arriving on a board must not duplicate the player");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -112,10 +120,8 @@ fn enter_board_unknown_entry_logs_error() {
|
||||
|
||||
#[test]
|
||||
fn try_move_onto_portal_switches_board() {
|
||||
let world = two_board_world();
|
||||
// Start the player one cell west of the portal at (2, 0).
|
||||
world.boards["b1"].borrow_mut().player = PlayerPos { x: 1, y: 0 };
|
||||
let mut game = GameState::from_world(world);
|
||||
// Start the player one cell west of b1's portal at (2, 0).
|
||||
let mut game = GameState::from_world(two_board_world_with_player((1, 0)));
|
||||
game.try_move(Direction::East);
|
||||
assert_eq!(game.current_board_name(), "b2");
|
||||
}
|
||||
|
||||
@@ -1,49 +1,13 @@
|
||||
mod actions;
|
||||
mod collision;
|
||||
// TODO(migration): map_file is not yet ported — it is blocked on the map files
|
||||
// themselves still being pre-BoardSpec (see todo.md #5).
|
||||
mod game_portals;
|
||||
mod map_file;
|
||||
// mod map_file;
|
||||
mod movement;
|
||||
mod scripting;
|
||||
|
||||
use crate::builtin::Archetype;
|
||||
use crate::board::Board;
|
||||
use crate::floor::Floor;
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::utils::PlayerPos;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// 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.scripting.script_name = object_script.map(str::to_string);
|
||||
let board = Board {
|
||||
name: "test".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
grid: vec![(Glyph::transparent(), Archetype::Empty)],
|
||||
floor: Floor::Blank,
|
||||
decorations: Vec::new(),
|
||||
sensors: Vec::new(),
|
||||
player: PlayerPos { x: 0, y: 0 },
|
||||
objects: BTreeMap::from([(1, object)]),
|
||||
next_object_id: 2,
|
||||
portals: Vec::new(),
|
||||
board_script_name: None,
|
||||
dark: false,
|
||||
load_errors: Vec::new(),
|
||||
registry: HashMap::new(),
|
||||
};
|
||||
(board, scripts_from(scripts))
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Converts a `(name, source)` slice into a `HashMap` suitable for
|
||||
/// [`GameState::with_scripts`].
|
||||
@@ -54,21 +18,6 @@ fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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.scripting.script_name = Some(script.to_string());
|
||||
o
|
||||
}
|
||||
|
||||
/// Returns a **non-solid** `ObjectDef` at `(x, y)` bound to the named script — the
|
||||
/// kind that receives an `enter` hook when a solid relocates onto its cell.
|
||||
fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
let mut o = scripted_object(x, y, script);
|
||||
o.behavior.solid = false;
|
||||
o
|
||||
}
|
||||
|
||||
/// Flattens each log line into a single string for easy assertions.
|
||||
fn log_texts(game: &GameState) -> Vec<String> {
|
||||
game.log
|
||||
|
||||
@@ -1,163 +1,173 @@
|
||||
use crate::builtin::Archetype;
|
||||
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
|
||||
//! Player push mechanics, driven through [`GameState::try_move`].
|
||||
//!
|
||||
//! Pushability now lives on the target's [`EnterResponse`]: `Push(Pushable::Any)`
|
||||
//! for a crate, `Push(Horizontal)`/`Push(Vertical)` for the axis-locked ones, and
|
||||
//! `Block` for a wall. `try_move` consults `Board::can_push` and, when the chain
|
||||
//! can move, pushes from the *player's* cell — the player is itself pushable, so it
|
||||
//! rides along at the head of the chain.
|
||||
|
||||
use crate::board::tests::{add_floor, builtin_at, crate_at, is_builtin, open_board, plain_object_at, wall_at};
|
||||
use crate::game::GameState;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::tile::EnterResponse;
|
||||
use crate::utils::{Direction, Pushable};
|
||||
use color::Rgba8;
|
||||
use crate::Builtin;
|
||||
|
||||
#[test]
|
||||
fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||
// The cell a crate is pushed off of becomes transparent and glyph_at shows the
|
||||
// floor on the layer beneath. After the push the player is at (1,0); check the
|
||||
// cell the player vacated (0,0) — it must reveal the floor glyph, not black.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
// The cell the player vacates becomes empty, and glyph_at falls through to the
|
||||
// board floor. After the push the player is at (1,0); check (0,0) — it must
|
||||
// reveal the floor glyph, not black.
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
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,
|
||||
},
|
||||
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
|
||||
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
|
||||
};
|
||||
add_floor(&mut board, floor_glyph); // floor below; terrain becomes layer 1
|
||||
add_floor(&mut board, floor_glyph);
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.player_pos(), (1, 0));
|
||||
assert!(is_builtin(&b, 2, 0, "crate"));
|
||||
assert!(b.get(0, 0).is_none()); // vacated
|
||||
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![]);
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.player_pos(), (1, 0));
|
||||
assert!(is_builtin(&b, 2, 0, "crate"));
|
||||
assert!(b.get(0, 0).is_none());
|
||||
}
|
||||
|
||||
#[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));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall"));
|
||||
assert_eq!(b.player_pos(), (0, 0));
|
||||
assert!(is_builtin(&b, 1, 0, "crate"));
|
||||
assert!(is_builtin(&b, 2, 0, "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![]);
|
||||
let mut board = open_board(2, 1, (0, 0));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.player_pos(), (0, 0));
|
||||
assert!(is_builtin(&b, 1, 0, "crate"));
|
||||
}
|
||||
|
||||
#[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));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.player_pos(), (1, 0));
|
||||
assert!(is_builtin(&b, 2, 0, "crate"));
|
||||
assert!(is_builtin(&b, 3, 0, "crate"));
|
||||
}
|
||||
|
||||
#[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));
|
||||
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::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
|
||||
assert_eq!(b.player_pos(), (0, 0));
|
||||
assert!(is_builtin(&b, 1, 0, "crate"));
|
||||
assert!(is_builtin(&b, 2, 0, "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.behavior.pushable = Pushable::Any;
|
||||
let board = open_board(4, 1, (0, 0), vec![obj]);
|
||||
// A plain object whose EnterResponse is Push(Any) is shoved exactly like a
|
||||
// crate — the crate builtin has no privileged status, it just carries that
|
||||
// same enter response.
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
let id = plain_object_at(&mut board, 1, 0, EnterResponse::Push(Pushable::Any));
|
||||
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));
|
||||
assert_eq!(b.player_pos(), (1, 0));
|
||||
assert_eq!(
|
||||
b.get_hookable(id).expect("object still on the board").location(),
|
||||
(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::Builtin(Builtin::HCrate, "hcrate"));
|
||||
// Pushing east: Push(Horizontal) allows it, so the HCrate slides.
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
builtin_at(&mut board, 1, 0, "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::Builtin(Builtin::HCrate, "hcrate"));
|
||||
drop(b);
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!(b.player_pos(), (1, 0));
|
||||
assert!(is_builtin(&b, 2, 0, "hcrate"));
|
||||
}
|
||||
|
||||
// Pushing north into an HCrate: blocked, nothing moves.
|
||||
let mut board = open_board(1, 4, (0, 3), vec![]);
|
||||
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::HCrate, "hcrate"));
|
||||
// Pushing north into an HCrate: the direction isn't allowed, so can_push fails
|
||||
// and the move degrades to a bump — nothing moves.
|
||||
let mut board = open_board(1, 4, (0, 3));
|
||||
builtin_at(&mut board, 0, 2, "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::Builtin(Builtin::HCrate, "hcrate"));
|
||||
assert_eq!(b.player_pos(), (0, 3));
|
||||
assert!(is_builtin(&b, 0, 2, "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::Builtin(Builtin::VCrate, "vcrate"));
|
||||
// Pushing north: Push(Vertical) allows it, so the VCrate slides.
|
||||
let mut board = open_board(1, 4, (0, 3));
|
||||
builtin_at(&mut board, 0, 2, "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::Builtin(Builtin::VCrate, "vcrate"));
|
||||
drop(b);
|
||||
{
|
||||
let b = game.board();
|
||||
assert_eq!(b.player_pos(), (0, 2));
|
||||
assert!(is_builtin(&b, 0, 1, "vcrate"));
|
||||
}
|
||||
|
||||
// Pushing east into a VCrate: blocked, nothing moves.
|
||||
let mut board = open_board(4, 1, (0, 0), vec![]);
|
||||
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::VCrate, "vcrate"));
|
||||
let mut board = open_board(4, 1, (0, 0));
|
||||
builtin_at(&mut board, 1, 0, "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::Builtin(Builtin::VCrate, "vcrate"));
|
||||
assert_eq!(b.player_pos(), (0, 0));
|
||||
assert!(is_builtin(&b, 1, 0, "vcrate"));
|
||||
}
|
||||
|
||||
+234
-241
@@ -1,14 +1,52 @@
|
||||
use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from};
|
||||
use crate::board::tests::{crate_at, open_board};
|
||||
use super::{log_texts, scripts_from};
|
||||
use crate::board::Board;
|
||||
use crate::board::tests::{object_at, open_board, plain_object_at, sensor_at};
|
||||
use crate::game::{GameState, ScrollLine};
|
||||
use crate::utils::Direction;
|
||||
use crate::tile::EnterResponse;
|
||||
use crate::utils::{Direction, ObjectId};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a 2×1 board with a single `Block` object at (0,0) that optionally
|
||||
/// references a script, the player parked at (1,0), and the `(name, source)`
|
||||
/// entries as a script map. Returns the object's id alongside them.
|
||||
///
|
||||
/// The player needs a cell of its own — one thing per cell — so this is 2×1 rather
|
||||
/// than the 1×1 board the pre-`Tile` version used.
|
||||
fn board_with_object(
|
||||
object_script: Option<&str>,
|
||||
scripts: &[(&str, &str)],
|
||||
) -> (Board, HashMap<String, String>, ObjectId) {
|
||||
let mut board = open_board(2, 1, (1, 0));
|
||||
let id = match object_script {
|
||||
Some(name) => object_at(&mut board, 0, 0, name, EnterResponse::Block),
|
||||
None => plain_object_at(&mut board, 0, 0, EnterResponse::Block),
|
||||
};
|
||||
(board, scripts_from(scripts), id)
|
||||
}
|
||||
|
||||
/// Whether object `id` currently carries `tag`.
|
||||
fn has_tag(game: &GameState, id: ObjectId, tag: &str) -> bool {
|
||||
game.board()
|
||||
.get_hookable(id)
|
||||
.expect("object still on the board")
|
||||
.tags()
|
||||
.contains(tag)
|
||||
}
|
||||
|
||||
/// The cell object `id` currently occupies.
|
||||
fn loc(game: &GameState, id: ObjectId) -> (usize, usize) {
|
||||
game.board()
|
||||
.get_hookable(id)
|
||||
.expect("object still on the board")
|
||||
.location()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let (board, scripts) = board_with_object(
|
||||
let (board, scripts, _) = board_with_object(
|
||||
Some("greet"),
|
||||
&[("greet", r#"fn init(m) { log("hello"); }"#)],
|
||||
&[("greet", r#"fn init(me) { log("hello"); }"#)],
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
// init must not fire during construction / deserialization.
|
||||
@@ -20,9 +58,9 @@ fn init_runs_only_on_run_init_not_at_construction() {
|
||||
|
||||
#[test]
|
||||
fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
let (board, scripts) = board_with_object(
|
||||
let (board, scripts, _) = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn tick(m,dt) { log(dt.to_string()); }"#)],
|
||||
&[("t", r#"fn tick(me, dt) { log(dt.to_string()); }"#)],
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
@@ -36,15 +74,16 @@ fn tick_calls_script_tick_with_elapsed_seconds() {
|
||||
|
||||
#[test]
|
||||
fn missing_hooks_and_no_script_are_noops() {
|
||||
// Object with no script: nothing happens.
|
||||
let (board, scripts) = board_with_object(None, &[]);
|
||||
// Object with no script at all: nothing happens.
|
||||
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 (board, scripts) = board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]);
|
||||
let (board, scripts, _) =
|
||||
board_with_object(Some("e"), &[("e", r#"fn other() { log("x"); }"#)]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
@@ -53,23 +92,30 @@ 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 (board, scripts) = board_with_object(Some("ghost"), &[]);
|
||||
// A reference to a script name that isn't in the pool. See todo.md #5: this is
|
||||
// currently a silent `continue` in ScriptHost::new, so nothing is logged.
|
||||
let (board, scripts, _) = board_with_object(Some("ghost"), &[]);
|
||||
let game = GameState::with_scripts(board, scripts);
|
||||
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
|
||||
assert!(
|
||||
log_texts(&game)
|
||||
.first()
|
||||
.is_some_and(|t| t.contains("unknown script 'ghost'")),
|
||||
"a script_name absent from the world pool should be reported"
|
||||
);
|
||||
|
||||
// A script that fails to compile is reported at construction time.
|
||||
let (board, scripts) = 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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_board_through_view() {
|
||||
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
|
||||
let mut board = open_board(5, 3, (3, 1));
|
||||
object_at(&mut board, 2, 1, "r", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("r", "fn init(m) { log(Player.x.to_string()); }")]),
|
||||
scripts_from(&[("r", "fn init(me) { log(Player.x.to_string()); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["3"]);
|
||||
@@ -77,34 +123,34 @@ fn script_reads_board_through_view() {
|
||||
|
||||
#[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")],
|
||||
);
|
||||
// Two objects with different scripts move in opposite directions; each must
|
||||
// affect only itself (the per-call tag routes the command to its source queue).
|
||||
let mut board = open_board(5, 3, (0, 0));
|
||||
let east = object_at(&mut board, 0, 1, "e", EnterResponse::Block);
|
||||
let west = object_at(&mut board, 4, 1, "w", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("e", "fn init(m) { move(East); }"),
|
||||
("w", "fn init(m) { move(West); }"),
|
||||
("e", "fn init(me) { move(East); }"),
|
||||
("w", "fn init(me) { move(West); }"),
|
||||
]),
|
||||
);
|
||||
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
|
||||
assert_eq!(loc(&game, east), (1, 1)); // moved east from 0
|
||||
assert_eq!(loc(&game, west), (3, 1)); // 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.
|
||||
// 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 with the engine.
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
||||
let mut world = crate::world::load(path).expect("load start.toml");
|
||||
// Keep the failure message short: a TomlError's Display embeds the whole file.
|
||||
let mut world = crate::world::load(path).unwrap_or_else(|e| {
|
||||
let first = e.to_string().lines().next().unwrap_or_default().to_string();
|
||||
panic!("load start.toml failed (see todo.md #5, maps are still pre-BoardSpec): {first}")
|
||||
});
|
||||
// Pin to the "start" board regardless of the world's current default entry point.
|
||||
world.start = "start".to_string();
|
||||
let mut game = GameState::from_world(world);
|
||||
@@ -115,52 +161,53 @@ fn start_map_greeter_runs_init() {
|
||||
.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"
|
||||
);
|
||||
let has_smiley = game
|
||||
.board()
|
||||
.all_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| game.board().get_hookable(id).map(|o| o.glyph().tile))
|
||||
.any(|tile| tile == 2);
|
||||
assert!(has_smiley, "greeter set_tile(2) should change its glyph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, scripts) = board_with_object(
|
||||
// A script calls set_tag(me.id, "active", true) in init; the tag must be present
|
||||
// on the object afterward.
|
||||
let (board, scripts, id) = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn init(m) { set_tag(m.id, "active", true); }"#)],
|
||||
&[("t", r#"fn init(me) { set_tag(me.id, "active", true); }"#)],
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert!(game.board().objects[&1].scripting.tags.contains("active"));
|
||||
assert!(has_tag(&game, id, "active"));
|
||||
|
||||
// A script removes a pre-existing tag.
|
||||
let (mut board2, scripts2) = board_with_object(
|
||||
let (mut board2, scripts2, id2) = board_with_object(
|
||||
Some("t2"),
|
||||
&[("t2", r#"fn init(m) { set_tag(m.id, "active", false); }"#)],
|
||||
&[("t2", r#"fn init(me) { set_tag(me.id, "active", false); }"#)],
|
||||
);
|
||||
// Seed the tag before construction.
|
||||
board2
|
||||
.objects
|
||||
.get_mut(&1)
|
||||
.scripting_mut(id2)
|
||||
.unwrap()
|
||||
.scripting
|
||||
.tags
|
||||
.insert("active".to_string());
|
||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||
game2.run_init();
|
||||
assert!(!game2.board().objects[&1].scripting.tags.contains("active"));
|
||||
assert!(!has_tag(&game2, id2, "active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, scripts) = board_with_object(
|
||||
// set_tag queues an action; has_tag reads board state. So set_tag in init() takes
|
||||
// effect after init returns, and a subsequent tick sees it.
|
||||
let (board, scripts, _) = board_with_object(
|
||||
Some("t"),
|
||||
&[(
|
||||
"t",
|
||||
r#"fn init(m) { set_tag(m.id, "active", true); }
|
||||
fn tick(m,dt) { log(m.has_tag("active").to_string()); }"#,
|
||||
r#"fn init(me) { set_tag(me.id, "active", true); }
|
||||
fn tick(me, dt) { log(me.has_tag("active").to_string()); }"#,
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
@@ -172,22 +219,25 @@ fn has_tag_reads_own_tags() {
|
||||
#[test]
|
||||
fn objects_with_tag_returns_matching_ids() {
|
||||
// A 5×1 board with two objects; one has tag "enemy". The script on the first
|
||||
// object queries objects_with_tag("enemy") and logs the ids it finds.
|
||||
let obj1 = scripted_object(0, 0, "q");
|
||||
let mut obj2 = scripted_object(1, 0, "none");
|
||||
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
|
||||
obj2.scripting.tags.insert("enemy".to_string());
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
// queries Board.tagged("enemy") and logs the count and the id it found.
|
||||
let mut board = open_board(5, 1, (4, 0));
|
||||
object_at(&mut board, 0, 0, "q", EnterResponse::Block);
|
||||
let enemy = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
|
||||
board
|
||||
.scripting_mut(enemy)
|
||||
.unwrap()
|
||||
.tags
|
||||
.insert("enemy".to_string());
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
(
|
||||
"q",
|
||||
r#"fn init(m) {
|
||||
let infos = Board.tagged("enemy");
|
||||
log(infos.len().to_string());
|
||||
log(infos[0].id.to_string());
|
||||
}"#,
|
||||
r#"fn init(me) {
|
||||
let infos = Board.tagged("enemy");
|
||||
log(infos.len().to_string());
|
||||
log(infos[0].id.to_string());
|
||||
}"#,
|
||||
),
|
||||
("none", ""),
|
||||
]),
|
||||
@@ -195,22 +245,24 @@ fn objects_with_tag_returns_matching_ids() {
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "1"); // exactly one match
|
||||
assert_eq!(texts[1], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[1], enemy.to_string());
|
||||
}
|
||||
|
||||
#[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, scripts) =
|
||||
board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]);
|
||||
board.objects.get_mut(&1).unwrap().scripting.name = Some("beacon".to_string());
|
||||
// An object with a name set should see it via me.name.
|
||||
let (mut board, scripts, id) =
|
||||
board_with_object(Some("n"), &[("n", r#"fn init(me) { log(me.name); }"#)]);
|
||||
board.scripting_mut(id).unwrap().name = Some("beacon".to_string());
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
assert_eq!(log_texts(&game), vec!["beacon"]);
|
||||
|
||||
// An unnamed object should get ().
|
||||
let (board2, scripts2) =
|
||||
board_with_object(Some("n"), &[("n", r#"fn init(m) { if m.name == () { log("null"); }}"#)]);
|
||||
let (board2, scripts2, _) = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init(me) { if me.name == () { log("null"); } }"#)],
|
||||
);
|
||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||
game2.run_init();
|
||||
assert_eq!(log_texts(&game2), vec!["null"]);
|
||||
@@ -218,85 +270,88 @@ fn my_name_returns_name_or_empty_string() {
|
||||
|
||||
#[test]
|
||||
fn object_id_for_name_finds_by_name() {
|
||||
// A board with two objects; one is named. The querying object uses
|
||||
// object_id_for_name to find the named one and logs its id.
|
||||
let obj1 = scripted_object(0, 0, "q");
|
||||
let mut obj2 = scripted_object(1, 0, "none");
|
||||
obj2.scripting.name = Some("target".to_string());
|
||||
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||
// A board with two objects; one is named. The querying object uses Board.named
|
||||
// to find it and logs its id, then confirms a miss returns ().
|
||||
let mut board = open_board(5, 1, (4, 0));
|
||||
object_at(&mut board, 0, 0, "q", EnterResponse::Block);
|
||||
let target = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
|
||||
board.scripting_mut(target).unwrap().name = Some("target".to_string());
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
(
|
||||
"q",
|
||||
r#"fn init(m) {
|
||||
log(Board.named("target").id.to_string());
|
||||
let miss = Board.named("missing");
|
||||
log(if miss == () { "not_found" } else { miss.id.to_string() });
|
||||
}"#,
|
||||
r#"fn init(me) {
|
||||
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
|
||||
assert_eq!(texts[0], target.to_string());
|
||||
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
|
||||
}
|
||||
|
||||
// Ensure try_move from the player side also triggers scripted bump
|
||||
// ── bump ────────────────────────────────────────────────────────────────────
|
||||
// `bump(me, dir)` fires when the player presses into a solid object — the only
|
||||
// remaining bump trigger, since object movement no longer dispatches hooks.
|
||||
|
||||
#[test]
|
||||
fn player_bump_reports_the_direction_it_came_from() {
|
||||
// The player walks East into a solid scripted object; the object is bumped from
|
||||
// the West side (opposite the player's travel) and the player does not move onto it.
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
// The player walks East into a Block object; the object is bumped from the West
|
||||
// side (opposite the player's travel) and the player does not move onto it.
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
object_at(&mut board, 1, 0, "b", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("b", "fn bump(m,dir) { log(`bumped from ${dir}`); }")]),
|
||||
scripts_from(&[("b", "fn bump(me, dir) { log(`bumped from ${dir}`); }")]),
|
||||
);
|
||||
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_eq!(game.board().player_pos(), (0, 0)); // blocked
|
||||
// log() bypasses the queue, so no tick is needed to flush it.
|
||||
assert!(log_texts(&game).iter().any(|t| t == "bumped from West"));
|
||||
}
|
||||
|
||||
// A bump handler can compare the direction and read its `dx`/`dy` offset to
|
||||
// locate the bumper's cell.
|
||||
#[test]
|
||||
fn bump_direction_supports_comparison_and_offset() {
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
|
||||
// A bump handler can compare the direction and read its dx/dy to locate the bumper.
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
object_at(&mut board, 1, 0, "b", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[(
|
||||
"b",
|
||||
// Player bumps from the West, so `dir == West` and the bumper sits at
|
||||
// (me.x + dir.dx) = 1 + (-1) = 0.
|
||||
"fn bump(m,dir) { if dir == West { log(`bumper at ${m.x + dir.dx}`); } }",
|
||||
"fn bump(me, dir) { if dir == West { log(`bumper at ${me.x + dir.dx}`); } }",
|
||||
)]),
|
||||
);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
|
||||
}
|
||||
|
||||
// ── scroll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
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")]);
|
||||
// A Block object's bump() calls scroll([text, [choice, display]]). The bump
|
||||
// resolves inside try_move, so active_scroll is set with the right variants.
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[(
|
||||
"s",
|
||||
r#"fn bump(m,dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
|
||||
r#"fn bump(me, dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
|
||||
)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move now, so the scroll is open immediately.
|
||||
game.try_move(Direction::East);
|
||||
|
||||
let scroll = game
|
||||
@@ -313,13 +368,13 @@ fn scroll_opens_on_player_bump() {
|
||||
|
||||
#[test]
|
||||
fn handle_scroll_without_choice_clears_it() {
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
|
||||
scripts_from(&[("s", r#"fn bump(me, dir) { scroll(["Hello"]); }"#)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move, so the scroll is open right away.
|
||||
game.try_move(Direction::East);
|
||||
assert!(game.active_scroll.is_some());
|
||||
|
||||
@@ -332,23 +387,23 @@ fn handle_scroll_without_choice_clears_it() {
|
||||
fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
// Setting choice on the scroll before a tick fires send() on the source object;
|
||||
// fn eat() logging "eaten" confirms the dispatch arrived.
|
||||
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[(
|
||||
"s",
|
||||
r#"
|
||||
fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
fn bump(me, dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
|
||||
fn eat() { log("eaten"); }
|
||||
"#,
|
||||
)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move, so the scroll is open right away.
|
||||
game.try_move(Direction::East);
|
||||
assert!(game.active_scroll.is_some());
|
||||
|
||||
// Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log.
|
||||
// Set the choice, then tick — handle_scroll dispatches "eat".
|
||||
game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string());
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(game.active_scroll.is_none());
|
||||
@@ -358,21 +413,26 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── ordering ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_later_object_sees_an_earlier_objects_move_this_tick() {
|
||||
// The core of the epic: each object's queued actions apply immediately, before
|
||||
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East
|
||||
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0).
|
||||
// Because A already moved there this tick, B observes it as blocked. Under the
|
||||
// old collect-all-then-apply model B would have seen (1,0) still empty.
|
||||
let a = scripted_object(0, 0, "a");
|
||||
let b = scripted_object(1, 1, "b");
|
||||
let board = open_board(3, 2, (2, 1), vec![a, b]);
|
||||
// Each object's queued actions apply immediately, before the next (higher-id)
|
||||
// object runs its hook. Object A at (0,0) moves East onto (1,0); object B at
|
||||
// (1,1) then checks the cell to its North (1,0). Because A already moved there
|
||||
// this tick, B observes it as blocked — under a collect-all-then-apply model B
|
||||
// would have seen (1,0) still empty.
|
||||
let mut board = open_board(3, 2, (2, 1));
|
||||
let a = object_at(&mut board, 0, 0, "a", EnterResponse::Block);
|
||||
object_at(&mut board, 1, 1, "b", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#),
|
||||
("a", "fn tick(me, dt) { move(East); }"),
|
||||
(
|
||||
"b",
|
||||
r#"fn tick(me, dt) { log(if me.blocked(North) { "blocked" } else { "clear" }); }"#,
|
||||
),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
@@ -380,7 +440,7 @@ fn a_later_object_sees_an_earlier_objects_move_this_tick() {
|
||||
game.tick(Duration::from_millis(16));
|
||||
|
||||
// A moved onto (1,0), and B saw it there the same tick.
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0));
|
||||
assert_eq!(loc(&game, a), (1, 0));
|
||||
assert_eq!(log_texts(&game), vec!["blocked"]);
|
||||
}
|
||||
|
||||
@@ -390,15 +450,13 @@ fn tick_is_gated_on_an_empty_queue() {
|
||||
// tick that just `move`s east paces itself one step per drained move instead of
|
||||
// piling up. `move` enqueues a Move plus a 0.25s Delay; while that Delay is still
|
||||
// draining the engine skips re-running `tick`. With 100 ms frames the object steps
|
||||
// once (x=1) then waits ~0.25s (the pending Delay) before the next call fires.
|
||||
// Under the old every-frame model an unguarded tick would enqueue a fresh move each
|
||||
// frame, racing the object east far faster.
|
||||
let obj = scripted_object(0, 0, "m");
|
||||
let board = open_board(6, 1, (5, 0), vec![obj]);
|
||||
// once (x=1) then waits ~0.25s before the next call fires.
|
||||
let mut board = open_board(6, 1, (5, 0));
|
||||
let id = object_at(&mut board, 0, 0, "m", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
// Note: no `if m.queue.length == 0` guard — the engine provides it.
|
||||
scripts_from(&[("m", "fn tick(m, dt) { move(East); }")]),
|
||||
// Note: no `if me.queue.length == 0` guard — the engine provides it.
|
||||
scripts_from(&[("m", "fn tick(me, dt) { move(East); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
|
||||
@@ -407,143 +465,78 @@ fn tick_is_gated_on_an_empty_queue() {
|
||||
for _ in 0..3 {
|
||||
game.tick(Duration::from_millis(100));
|
||||
}
|
||||
assert_eq!(game.board().objects[&1].x, 1);
|
||||
assert_eq!(loc(&game, id).0, 1);
|
||||
|
||||
// Once the Delay fully drains the queue empties, so the next frame calls `tick`
|
||||
// again and the object takes its second step.
|
||||
game.tick(Duration::from_millis(100));
|
||||
assert_eq!(game.board().objects[&1].x, 2);
|
||||
assert_eq!(loc(&game, id).0, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_cycle_terminates_via_the_called_guard() {
|
||||
// Two objects send "go" to each other in a cycle. Without the per-invocation
|
||||
// "already-called" guard this would recurse forever; with it, each (object, fn,
|
||||
// args) fires at most once, so the cascade settles after one round-trip. That the
|
||||
// call returns at all — and logs exactly one "B" then one "A" — proves it.
|
||||
let a = scripted_object(0, 0, "a");
|
||||
let b = scripted_object(1, 0, "b");
|
||||
let board = open_board(3, 1, (2, 0), vec![a, b]);
|
||||
fn a_send_cycle_defers_instead_of_recursing() {
|
||||
// Two objects send "poke" to each other in a cycle. `run_send` invokes the target
|
||||
// function directly but deliberately does *not* drain its queue, so the re-send
|
||||
// lands in the target's queue and fires on the next drain instead of recursing
|
||||
// inside this dispatch. The cycle therefore never settles — but it advances a
|
||||
// bounded amount per tick, so the game keeps running rather than hanging.
|
||||
//
|
||||
// This is the regression guard for that design: draining inline (an
|
||||
// easy-looking optimisation, warned against in run_send's doc comment) would
|
||||
// recurse until the stack blows. That this test *returns at all* is half the
|
||||
// assertion; the log growth is the other half.
|
||||
let mut board = open_board(3, 1, (2, 0));
|
||||
let a = object_at(&mut board, 0, 0, "a", EnterResponse::Block);
|
||||
let b = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#),
|
||||
("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#),
|
||||
(
|
||||
"a",
|
||||
&format!(
|
||||
r#"fn init(me) {{ send({b}, "poke"); }} fn poke(me) {{ log("A"); send({b}, "poke"); }}"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"b",
|
||||
&format!(r#"fn poke(me) {{ log("B"); send({a}, "poke"); }}"#),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
game.run_init();
|
||||
|
||||
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send);
|
||||
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
|
||||
// One round-trip: A.init's send runs B.poke, whose re-send is queued on B and
|
||||
// drained later in the same run_init pass, running A.poke. A's re-send waits.
|
||||
assert_eq!(log_texts(&game), vec!["B", "A"]);
|
||||
|
||||
// Each subsequent tick advances the cycle by exactly one more round-trip.
|
||||
for n in 1..=3 {
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert_eq!(
|
||||
log_texts(&game).len(),
|
||||
2 * (n + 1),
|
||||
"tick {n} should add exactly one B/A round-trip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── enter hook ──────────────────────────────────────────────────────────────
|
||||
// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its
|
||||
// cell. `dir` is the side the entrant came from (opposite the travel direction
|
||||
// for a cardinal move/push; best-effort for teleport/shift).
|
||||
// `enter(me, dir)` fires on a **sensor** when the player steps onto its cell, with
|
||||
// `dir` the side the player came from. This is the only remaining enter trigger:
|
||||
// object movement, pushes, teleports and shifts dispatch no hooks (see todo.md).
|
||||
|
||||
#[test]
|
||||
fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() {
|
||||
// The player walks East onto a non-solid trigger; it entered from the West.
|
||||
let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]);
|
||||
fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() {
|
||||
// The player walks East onto a sensor; it entered from the West.
|
||||
let mut board = open_board(3, 1, (0, 0));
|
||||
sensor_at(&mut board, 1, 0, "e");
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
|
||||
scripts_from(&[("e", "fn enter(me, dir) { log(`entered from ${dir}`); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
// The player is not blocked by a non-solid — it moves onto the cell.
|
||||
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
|
||||
// A sensor is off-grid, so it never blocks: the player moves onto the cell.
|
||||
assert_eq!(game.board().player_pos(), (1, 0));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_moving_onto_a_nonsolid_fires_enter() {
|
||||
// A solid mover ticks East onto a non-solid trigger sharing the destination.
|
||||
let mover = scripted_object(0, 0, "m");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_a_crate_onto_a_nonsolid_fires_enter() {
|
||||
// Player pushes a crate East onto a non-solid trigger's cell; the crate is the
|
||||
// solid entrant, so `enter` fires (from West).
|
||||
let mut board = open_board(4, 1, (0, 0), vec![nonsolid_object(2, 0, "e")]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleporting_onto_a_nonsolid_fires_enter() {
|
||||
// A solid object teleports itself from (0,0) onto the non-solid trigger at (1,0).
|
||||
let mover = scripted_object(0, 0, "t");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("t", "fn init(m) { teleport(m.id, 1, 0); }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
// Best-effort direction: the jump was one cell east, so it entered from West.
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_a_crate_onto_a_nonsolid_fires_enter() {
|
||||
// An object shifts the ring [(1,0),(2,0)]: the crate at (1,0) rotates onto the
|
||||
// non-solid trigger at (2,0), firing `enter` (best-effort West).
|
||||
let shifter = nonsolid_object(0, 0, "s");
|
||||
let trigger = nonsolid_object(2, 0, "e");
|
||||
let mut board = open_board(4, 1, (3, 0), vec![shifter, trigger]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("s", "fn init(m) { shift([[1, 0], [2, 0]]); }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonsolid_mover_onto_a_nonsolid_does_not_fire_enter() {
|
||||
// Only *solid* entrants trigger enter: a non-solid mover walking onto another
|
||||
// non-solid must NOT fire it.
|
||||
let mover = nonsolid_object(0, 0, "m");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(!log_texts(&game).iter().any(|t| t.starts_with("entered")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user