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")); }