2026-06-07 00:46:38 -05:00
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 01:26:18 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn set_tag_adds_and_removes_via_my_id() {
|
|
|
|
|
|
// A script calls set_tag(MY_ID, "active", true) in init; the tag must be
|
|
|
|
|
|
// present on the object afterward.
|
|
|
|
|
|
let board = board_with_object(
|
|
|
|
|
|
Some("t"),
|
|
|
|
|
|
&[("t", r#"fn init() { set_tag(MY_ID, "active", true); }"#)],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
assert!(game.board().objects[&1].tags.contains("active"));
|
|
|
|
|
|
|
|
|
|
|
|
// A script removes a pre-existing tag.
|
|
|
|
|
|
let board2 = board_with_object(
|
|
|
|
|
|
Some("t2"),
|
|
|
|
|
|
&[("t2", r#"fn init() { set_tag(MY_ID, "active", false); }"#)],
|
|
|
|
|
|
);
|
|
|
|
|
|
// Seed the tag before construction.
|
|
|
|
|
|
let mut game2 = GameState::new({
|
|
|
|
|
|
let mut b = board2; // need mut to insert tag
|
|
|
|
|
|
b.objects.get_mut(&1).unwrap().tags.insert("active".to_string());
|
|
|
|
|
|
b
|
|
|
|
|
|
});
|
|
|
|
|
|
game2.run_init();
|
|
|
|
|
|
assert!(!game2.board().objects[&1].tags.contains("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 = board_with_object(
|
|
|
|
|
|
Some("t"),
|
|
|
|
|
|
&[(
|
|
|
|
|
|
"t",
|
|
|
|
|
|
r#"fn init() { set_tag(MY_ID, "active", true); }
|
|
|
|
|
|
fn tick(dt) { log(has_tag("active").to_string()); }"#,
|
|
|
|
|
|
)],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
assert!(log_texts(&game).iter().any(|t| t == "true"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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.tags.insert("enemy".to_string());
|
|
|
|
|
|
let board = open_board(
|
|
|
|
|
|
5,
|
|
|
|
|
|
1,
|
|
|
|
|
|
(4, 0),
|
|
|
|
|
|
vec![obj1, obj2],
|
|
|
|
|
|
&[
|
|
|
|
|
|
("q", r#"fn init() {
|
|
|
|
|
|
let ids = objects_with_tag("enemy");
|
|
|
|
|
|
log(ids.len().to_string());
|
|
|
|
|
|
log(ids[0].to_string());
|
|
|
|
|
|
}"#),
|
|
|
|
|
|
("none", ""),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:46:38 -05:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|