Files
kiln/kiln-core/src/tests/scripting.rs
T
2026-06-08 22:15:44 -05:00

263 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::time::Duration;
use crate::board::tests::open_board;
use crate::game::GameState;
use crate::utils::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"
);
}
#[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
}
#[test]
fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name().
let mut board = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(my_name()); }"#)],
);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::new(board);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string.
let board2 = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(my_name()); }"#)],
);
let mut game2 = GameState::new(board2);
game2.run_init();
assert_eq!(log_texts(&game2), vec![""]);
}
#[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.name = Some("target".to_string());
let board = open_board(
5,
1,
(4, 0),
vec![obj1, obj2],
&[
("q", r#"fn init() {
log(object_id_for_name("target").to_string());
log(object_id_for_name("missing").to_string());
}"#),
("none", ""),
],
);
let mut game = GameState::new(board);
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[1], "0"); // 0 = not found
}
// 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"));
}