From 3edb0b9eece9baaff0fe3621cb14fb3e5c1e3af2 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Tue, 11 Aug 2026 23:22:16 -0500 Subject: [PATCH] wip 7 more test fixing --- docs/script-api.md | 19 +- kiln-core/src/action.rs | 4 +- kiln-core/src/board_spec.rs | 208 +++++++++++++++++- kiln-core/src/game.rs | 67 +++++- kiln-core/src/script.rs | 19 +- kiln-core/src/scripts/crate.rhai | 2 +- kiln-core/src/scripts/gem.rhai | 8 +- kiln-core/src/scripts/heart.rhai | 4 +- kiln-core/src/scripts/key.rhai | 8 +- kiln-core/src/scripts/pusher.rhai | 2 +- kiln-core/src/tests/map_file/fill_sparse.rs | 100 --------- kiln-core/src/tests/map_file/grid_errors.rs | 59 ----- kiln-core/src/tests/map_file/mod.rs | 64 ------ .../src/tests/map_file/object_placement.rs | 82 ------- .../src/tests/map_file/player_placement.rs | 64 ------ .../src/tests/map_file/portal_placement.rs | 56 ----- kiln-core/src/tests/map_file/pushers.rs | 139 ------------ kiln-core/src/tests/map_file/round_trip.rs | 157 ------------- kiln-core/src/tests/map_file/spinners.rs | 60 ----- kiln-core/src/tests/map_file/transporters.rs | 151 ------------- kiln-core/src/tests/mod.rs | 2 - kiln-core/src/utils.rs | 3 +- 22 files changed, 284 insertions(+), 994 deletions(-) delete mode 100644 kiln-core/src/tests/map_file/fill_sparse.rs delete mode 100644 kiln-core/src/tests/map_file/grid_errors.rs delete mode 100644 kiln-core/src/tests/map_file/mod.rs delete mode 100644 kiln-core/src/tests/map_file/object_placement.rs delete mode 100644 kiln-core/src/tests/map_file/player_placement.rs delete mode 100644 kiln-core/src/tests/map_file/portal_placement.rs delete mode 100644 kiln-core/src/tests/map_file/pushers.rs delete mode 100644 kiln-core/src/tests/map_file/round_trip.rs delete mode 100644 kiln-core/src/tests/map_file/spinners.rs delete mode 100644 kiln-core/src/tests/map_file/transporters.rs diff --git a/docs/script-api.md b/docs/script-api.md index f258803..b0c00e7 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -42,7 +42,7 @@ actions (and any pacing `Delay`) have fully drained. While actions from an earli pending, the engine just advances the queue by `dt` and does **not** re-run `tick`. This means you no longer need to guard the body with `if me.queue.length == 0` / `if !me.waiting` — the engine does it for you, so a plain `move(North)` naturally paces itself one step per drain. `dt` is the elapsed time -since the last tick, in seconds (a `f64`). (All other hooks — `bump`/`enter`/`grab`/`send`/`init` — +since the last tick, in seconds (a `f64`). (All other hooks — `bump`/`enter`/`send`/`init` — still fire regardless of queued actions.) ```rhai @@ -91,23 +91,6 @@ fn enter(me, dir) { } ``` -### `fn grab(me, state)` - -Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts — -`solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a -player stat and removes the object. It fires followed by an immediate resolve, so any `die()` / -`alter_gems()` / `alter_health()` applies before the player's move returns. - -```rhai -fn grab(me, state) { - alter_gems(1); - die(); -} -``` - -> Grab is **only** triggered by the player walking onto the object. A grab thing *pushed* or -> *shifted* into the player is treated as an ordinary solid (no `grab()`). - ### Custom handler functions Any other function can be invoked on an object two ways: diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index c0a5b63..584f922 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -125,8 +125,8 @@ pub enum Action { /// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and /// ignored. Zero time cost. Applied to `GameState::player.keys`. SetKey(String, bool), - /// Remove the source object from the board. Zero time cost. Used by grab - /// things (e.g. gems) to despawn themselves from their `grab()` hook. + /// Remove the source object from the board. Zero time cost. Used by + /// collectibles (e.g. gems) to despawn themselves from their `bump()` hook. Die, } diff --git a/kiln-core/src/board_spec.rs b/kiln-core/src/board_spec.rs index 981d30b..346aa35 100644 --- a/kiln-core/src/board_spec.rs +++ b/kiln-core/src/board_spec.rs @@ -108,7 +108,7 @@ impl BoardSpec { } else if self.palette.contains_key(&ch) { cells.push(Some(self.palette[&ch].clone())) } else { - return Err(format!("unknown grid character '{ch}' at ({x}, {y}); using error block")); + return Err(format!("unknown grid character '{ch}' at ({x}, {y}): not in the palette")); } } } @@ -122,9 +122,9 @@ impl BoardSpec { // Check for player being positioned exactly once let players = grid.iter().filter(|c| matches!(c, Some(TileSpec::Player))).count(); if players == 0 { - errors.push("no player cell (kind = \"player\") found".to_string()) + errors.push("no player cell (type = \"player\") found".to_string()) } else if players > 1 { - errors.push("player appears {players} times, can only appear once".to_string()) + errors.push(format!("player appears {players} times, can only appear once")) } // Check for duplicate object or portal names @@ -138,10 +138,18 @@ impl BoardSpec { else { hash.insert(name.clone(), 1); } } + // Only a *named* object takes part in the uniqueness check, but every object's + // script has to resolve — so the two are collected independently (nesting the + // script check inside the name pattern let an unnamed object reference a + // script that doesn't exist). for cell in grid.iter() { - if let Some(TileSpec::Object { name: Some(name), script, .. }) = cell { - count(&mut obj_names, name); - script.as_ref().map(|script_name| obj_script_names.insert(script_name)); + if let Some(TileSpec::Object { name, script, .. }) = cell { + if let Some(name) = name { + count(&mut obj_names, name); + } + if let Some(script_name) = script { + obj_script_names.insert(script_name); + } } } @@ -163,7 +171,7 @@ impl BoardSpec { let portal_dupes = portal_names.into_iter().filter_map(|(name, count)| if count > 1 { Some(name) } else { None }).collect::>(); let portal_loc_dupes = portal_locations.into_iter().filter_map(|(loc, count)| { if count > 1 { - Some(format!("({}, {}", loc % self.width, loc / self.width)) + Some(format!("({}, {})", loc % self.width, loc / self.width)) } else { None } }).collect::>(); @@ -223,4 +231,188 @@ impl BoardSpec { Ok(board) } -} \ No newline at end of file +} +#[cfg(test)] +mod tests { + use super::BoardSpec; + use crate::board::Board; + use crate::tile::Tile; + use std::collections::HashSet; + + /// A palette entry for a wall / the player, the two building blocks these tests + /// need most. + const WALL: (&str, &str) = ("#", r#"{ type = "builtin", kind = "wall" }"#); + const PLAYER: (&str, &str) = ("@", r#"{ type = "player" }"#); + + /// A `type = "object"` palette body. `Glyph`'s colors have no serde default, so + /// every object entry has to carry `fg`/`bg`; `extra` appends the fields a given + /// test cares about (`name = "…"`, `script = "…"`). + fn object(extra: &str) -> String { + format!(r##"{{ type = "object", tile = "O", fg = "#00ffff", bg = "#000000"{extra} }}"##) + } + + /// Renders a whole board table: the `[map]`-style header, the grid, its palette, + /// and any extra TOML (`[[portals]]` / `[[sensors]]` blocks) appended verbatim. + fn spec_toml(w: usize, h: usize, grid: &str, palette: &[(&str, &str)], extra: &str) -> String { + let entries: String = palette + .iter() + .map(|(ch, body)| format!("\"{ch}\" = {body}\n")) + .collect(); + format!("name = \"Test\"\nwidth = {w}\nheight = {h}\ngrid = \"\"\"\n{grid}\n\"\"\"\n\n[palette]\n{entries}{extra}") + } + + /// Parses a board table and converts it, with `scripts` standing in for the + /// world's script pool (the names `world::load` validates object scripts against). + fn build(toml_src: &str, scripts: &[&str]) -> Result { + let spec: BoardSpec = toml::from_str(toml_src).expect("board table parses"); + let owned: Vec = scripts.iter().map(|s| s.to_string()).collect(); + let names: HashSet<&String> = owned.iter().collect(); + spec.into_board(&names) + } + + /// `build`, asserting the conversion failed, and returning the joined error text. + fn build_err(toml_src: &str, scripts: &[&str]) -> String { + // `Board` is not `Debug`, so unwrap the error by hand rather than expect_err. + build(toml_src, scripts) + .err() + .expect("conversion should have failed") + } + + #[test] + fn a_valid_spec_builds_its_grid() { + let board = build(&spec_toml(3, 1, "#@ ", &[WALL, PLAYER], ""), &[]) + .expect("a well-formed spec converts"); + + assert_eq!((board.width, board.height), (3, 1)); + assert!(matches!(board.get((0, 0)), Some(Tile::Object(_))), "wall"); + assert!(matches!(board.get((1, 0)), Some(Tile::Player))); + assert!(board.get((2, 0)).is_none(), "a space is always an empty cell"); + // The wall is a real object, so it was handed an id and the counter moved on. + assert_eq!(board.all_ids().len(), 1); + assert!(board.next_object_id > 1); + } + + #[test] + fn portals_and_sensors_load_alongside_the_grid() { + // Neither lives in the grid: the player has to be able to share their cell. + let extra = r#" +[[portals]] +x = 2 +y = 0 +name = "east_door" +target_board = "room2" +target_name = "west_door" + +[[sensors]] +x = 0 +y = 0 +script = "tripwire" +"#; + let board = build(&spec_toml(3, 1, " @ ", &[PLAYER], extra), &["tripwire"]) + .expect("portals and sensors convert"); + + assert_eq!(board.named_portal("east_door").map(|p| p.location.ux()), Some(2)); + assert_eq!(board.sensor_ids_at((0, 0)).len(), 1); + // The sensor shares the id space with grid objects rather than its own. + assert_eq!(board.all_ids().len(), 1); + } + + #[test] + fn grid_row_count_must_match_the_height() { + let err = build_err(&spec_toml(3, 3, "@..\n...", &[PLAYER, (".", WALL.1)], ""), &[]); + assert!(err.contains("2 rows"), "got: {err}"); + assert!(err.contains("3 tall"), "got: {err}"); + } + + #[test] + fn grid_row_width_must_match_the_width() { + let err = build_err(&spec_toml(4, 2, "@...\n...", &[PLAYER, (".", WALL.1)], ""), &[]); + assert!(err.contains("row 1"), "the offending row is named; got: {err}"); + assert!(err.contains("3 characters"), "got: {err}"); + assert!(err.contains("4 wide"), "got: {err}"); + } + + #[test] + fn an_unknown_grid_char_is_an_error() { + // A char with no palette entry used to load as a visible ErrorBlock; the + // strict loader rejects the whole board instead. (A space is exempt — it is + // always an empty cell and is never a palette key.) + let err = build_err(&spec_toml(3, 1, "@?.", &[PLAYER, (".", WALL.1)], ""), &[]); + assert!(err.contains("unknown grid character '?'"), "got: {err}"); + assert!(err.contains("(1, 0)"), "the offending cell is named; got: {err}"); + } + + #[test] + fn a_board_needs_exactly_one_player() { + let missing = build_err(&spec_toml(3, 1, "## ", &[WALL], ""), &[]); + assert!(missing.contains("no player cell"), "got: {missing}"); + + let doubled = build_err(&spec_toml(3, 1, "@@ ", &[PLAYER], ""), &[]); + assert!(doubled.contains("player appears 2 times"), "got: {doubled}"); + } + + #[test] + fn object_names_must_be_unique() { + // Two objects both claiming "gate". This used to be nonfatal (the second had + // its name cleared); it now rejects the board. + let gate = object(r#", name = "gate""#); + let palette = [PLAYER, ("G", gate.as_str()), ("H", gate.as_str())]; + let err = build_err(&spec_toml(3, 1, "GH@", &palette, ""), &[]); + assert!(err.contains("Object names used multiple times"), "got: {err}"); + assert!(err.contains("gate"), "got: {err}"); + } + + #[test] + fn portal_names_and_locations_must_be_unique() { + let same_name = r#" +[[portals]] +x = 0 +y = 0 +name = "door" +target_board = "b" +target_name = "other" + +[[portals]] +x = 2 +y = 0 +name = "door" +target_board = "b" +target_name = "other" +"#; + let err = build_err(&spec_toml(3, 1, " @ ", &[PLAYER], same_name), &[]); + assert!(err.contains("Portal names used multiple times"), "got: {err}"); + + let same_cell = r#" +[[portals]] +x = 2 +y = 0 +name = "door" +target_board = "b" +target_name = "other" + +[[portals]] +x = 2 +y = 0 +name = "hatch" +target_board = "b" +target_name = "other" +"#; + let err = build_err(&spec_toml(3, 1, " @ ", &[PLAYER], same_cell), &[]); + assert!(err.contains("Portal locations used multiple times"), "got: {err}"); + assert!(err.contains("(2, 0)"), "got: {err}"); + } + + #[test] + fn an_object_script_must_exist_in_the_world_pool() { + let ghost = object(r#", script = "ghost""#); + let palette = [PLAYER, ("G", ghost.as_str())]; + let toml_src = spec_toml(3, 1, "G@ ", &palette, ""); + + let err = build_err(&toml_src, &[]); + assert!(err.contains("Missing scripts"), "got: {err}"); + assert!(err.contains("ghost"), "got: {err}"); + + // The same board converts once the world supplies that script. + assert!(build(&toml_src, &["ghost"]).is_ok()); + } +} diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 1ba0fbc..07f1a64 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -474,7 +474,7 @@ impl GameState { mod tests { use super::GameState; use crate::Direction; - use crate::utils::Point; + use crate::utils::{ObjectId, Point}; use crate::board::tests::{ builtin_at, crate_at, gem_at, is_builtin, open_board, sensor_at, wall_at, }; @@ -674,6 +674,71 @@ mod tests { assert_eq!(seq, vec!['/', '─', '\\', '│', '/']); } + // ── pushers ───────────────────────────────────────────────────────────── + // A pusher is a self-propelled solid: `scripts/pusher.rhai` queues one `move` + // in its facing direction per ~0.5s. Shoving things aside is not special-cased + // — the pusher's move presses into whatever is ahead, and that thing's `bump` + // hook decides whether to clear the way (a crate does, a wall has no script). + + /// Builds a 1-row board `w` wide with an east-facing pusher at (0,0), crates at + /// `crates`, walls at `walls`, and the player parked on the east edge; runs + /// `init()` and returns the game plus the pusher's id. + fn pusher_board( + w: usize, + crates: &[(usize, usize)], + walls: &[(usize, usize)], + ) -> (GameState, ObjectId) { + let mut board = open_board(w, 1, (w - 1, 0)); + let id = builtin_at(&mut board, 0, 0, "pusher_east"); + for &(x, y) in crates { + crate_at(&mut board, x, y); + } + for &(x, y) in walls { + wall_at(&mut board, x, y); + } + // No world script pool: a builtin carries its own ScriptKey and source. + let mut game = GameState::new(board); + game.run_init(); + (game, id) + } + + /// Where object `id` currently sits. + fn object_pos(game: &GameState, id: ObjectId) -> Point { + game.board() + .get_hookable(id) + .expect("object still on the board") + .location() + } + + #[test] + fn pusher_advances_and_shoves_a_crate() { + // Pusher at (0,0), crate at (1,0), the player parked at the east edge (4,0). + // 3.6s is far more than the pusher needs, so this settles: the crate is + // shoved up against the player, who has the board edge at its back and so + // cannot be pushed further, which in turn parks the pusher behind the crate. + let (mut game, id) = pusher_board(5, &[(1, 0)], &[]); + for _ in 0..12 { + game.tick(Duration::from_secs_f64(0.3)); + } + + let b = game.board(); + assert_eq!(object_pos(&game, id), Point { x: 2, y: 0 }, "pusher advanced east"); + assert!(is_builtin(&b, 3, 0, "crate"), "crate shoved up against the player"); + assert!(b.get((1, 0)).is_none(), "crate left its start cell"); + assert_eq!(b.player_pos(), Point { x: 4, y: 0 }, "player pinned by the edge"); + } + + #[test] + fn pusher_blocked_by_wall_stays_put() { + // A wall has no `bump` hook, so nothing ever clears its cell: the pusher + // re-attempts the same move forever and never leaves (0,0). + let (mut game, id) = pusher_board(4, &[], &[(1, 0)]); + for _ in 0..12 { + game.tick(Duration::from_secs_f64(0.3)); + } + assert_eq!(object_pos(&game, id), Point { x: 0, y: 0 }); + } + #[test] fn set_key_gives_and_takes_keys() { let mut board = open_board(2, 1, (1, 0)); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 0c62f42..e88fd6c 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -11,8 +11,6 @@ //! - `enter(me, dir)` — run when a solid (the player, an object, or a pushed crate) relocates *onto* //! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for //! teleport/shift); see [`ScriptHost::run_enter`]. -//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`]. -//! Typically adds a stat + `die()`s. //! //! | Name | Type | Description | //! |---|---|---| @@ -67,7 +65,6 @@ struct CompiledScript { has_init: bool, has_tick: bool, has_bump: bool, - has_grab: bool, has_enter: bool, } @@ -76,7 +73,6 @@ impl CompiledScript { match hook { Hook::Init => self.has_init, Hook::Tick => self.has_tick, - Hook::Grab => self.has_grab, Hook::Bump => self.has_bump, Hook::Enter => self.has_enter } @@ -157,7 +153,6 @@ impl ScriptHost { has_init: defines("init", 1), has_tick: defines("tick", 2), has_bump: defines("bump", 2), - has_grab: defines("grab", 1), has_enter: defines("enter", 2), ast, }, @@ -227,8 +222,8 @@ impl ScriptHost { // `tick` only fires on an object whose previous output has fully // drained. A non-empty queue means we just advance the pending // actions this frame (the unconditional drain below) without - // re-running the script. Every other hook (init/bump/enter/grab/ - // send) fires regardless of queued actions. + // re-running the script. Every other hook (init/bump/enter/send) + // fires regardless of queued actions. if script.has(hook) && (hook != Hook::Tick || info.queue.is_empty()) { let mut args = vec![Dynamic::from(info.clone())]; if let Some(d) = arg { args.push(d) } @@ -270,16 +265,6 @@ impl ScriptHost { self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0) } - /// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the - /// hook, and returns the actions it drained. - /// - /// Fired when the player walks onto a grab object (see - /// [`GameState`](crate::game::GameState)). The hook typically increments a - /// player stat and removes the object via `die()`. - pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec { - self.run_hook_on_one(Hook::Grab, object_id, None, 0.0) - } - /// Calls the named function on the object with [`ObjectId`] `target_id`. /// /// What we pass depends on arity, in this order: diff --git a/kiln-core/src/scripts/crate.rhai b/kiln-core/src/scripts/crate.rhai index 17e527e..032f322 100644 --- a/kiln-core/src/scripts/crate.rhai +++ b/kiln-core/src/scripts/crate.rhai @@ -1,4 +1,4 @@ -// Built-in script for the `crate` archetype (see kiln-core/src/builtin_scripts.rs). +// Built-in script for the `crate` archetype (see kiln-core/src/builtin.rs). fn bump(me, dir) { push(me.x, me.y, dir.opposite); // Try to clear our target cell let tgt = dir.opposite.from_point(me.x, me.y); diff --git a/kiln-core/src/scripts/gem.rhai b/kiln-core/src/scripts/gem.rhai index f342950..4556bcd 100644 --- a/kiln-core/src/scripts/gem.rhai +++ b/kiln-core/src/scripts/gem.rhai @@ -1,8 +1,8 @@ -// Built-in script for the `gem` archetype (see kiln-core/src/builtin_scripts.rs). +// Built-in script for the `gem` archetype (see kiln-core/src/builtin.rs). // -// A gem is a grabbable collectible: walking onto it (or pushing it into the -// player) fires this `grab()` hook instead of blocking. We bump the player's gem -// count and remove ourselves from the board. +// A gem is a collectible: it is solid, so walking into it fires this `bump()` +// hook. We bank the gem and `die()`, which clears the cell — so the move that +// bumped us completes and the player ends up standing where the gem was. fn bump(me, _dir) { alter_gems(1); die(); diff --git a/kiln-core/src/scripts/heart.rhai b/kiln-core/src/scripts/heart.rhai index 98074ea..e59600f 100644 --- a/kiln-core/src/scripts/heart.rhai +++ b/kiln-core/src/scripts/heart.rhai @@ -1,7 +1,7 @@ // Built-in script for the `heart` archetype. // -// A heart is a grabbable collectible: walking onto it fires `grab()` instead -// of blocking. It restores 1 health and removes itself from the board. +// A heart is a collectible: walking into it fires `bump()`, which restores 1 +// health and removes the heart, clearing the cell for the bumper to move into. fn bump(me, _dir) { alter_health(1); die(); diff --git a/kiln-core/src/scripts/key.rhai b/kiln-core/src/scripts/key.rhai index 0f05650..dac0996 100644 --- a/kiln-core/src/scripts/key.rhai +++ b/kiln-core/src/scripts/key.rhai @@ -1,8 +1,8 @@ -// Built-in script for the `gem` archetype (see kiln-core/src/builtin_scripts.rs). +// Built-in script for the `key_*` archetypes. // -// A gem is a grabbable collectible: walking onto it (or pushing it into the -// player) fires this `grab()` hook instead of blocking. We bump the player's gem -// count and remove ourselves from the board. +// A key is a collectible: walking into it fires `bump()`. We read our own colour +// off the `BUILTIN_key_` tag, add it to the player's keyring, and `die()` +// so the cell clears for the bumper to move into. fn bump(me, _dir) { let colors = [ "red", diff --git a/kiln-core/src/scripts/pusher.rhai b/kiln-core/src/scripts/pusher.rhai index 9752179..78ec343 100644 --- a/kiln-core/src/scripts/pusher.rhai +++ b/kiln-core/src/scripts/pusher.rhai @@ -1,4 +1,4 @@ -// Built-in script for the `pusher_*` archetypes (see kiln-core/src/builtin_scripts.rs). +// Built-in script for the `pusher_*` archetypes (see kiln-core/src/builtin.rs). // // A pusher is a self-propelled solid that advances one cell in its facing // direction every ~0.5s, shoving any pushable chain (including the player) ahead diff --git a/kiln-core/src/tests/map_file/fill_sparse.rs b/kiln-core/src/tests/map_file/fill_sparse.rs deleted file mode 100644 index 60fe207..0000000 --- a/kiln-core/src/tests/map_file/fill_sparse.rs +++ /dev/null @@ -1,100 +0,0 @@ -use super::load_board; -use crate::builtin::Archetype; - -#[test] -fn fill_builds_a_full_grid_of_one_char() { - // `fill` fills the whole single grid with one palette char. No player char is - // possible in a filled grid, so the player falls back to (0, 0) and wins (clears) - // that cell; every *other* cell is the filled archetype. - let toml = r##" -[map] -name = "Test" -width = 3 -height = 2 - -[grid] -fill = "#" -palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } } -"##; - let board = load_board(toml); - for y in 0..2 { - for x in 0..3 { - if (x, y) == (0, 0) { // player won its fallback cell - assert!(board.solid_at(x, y).unwrap().player()); - } else { - let obj = &board.objects[&board.object_ids_at(x, y)[0]]; - let tag = obj.scripting.tags.iter().next().unwrap(); - assert_eq!(tag, "BUILTIN_wall") - } - - } - } - assert_eq!((board.player.x, board.player.y), (0, 0)); -} - -#[test] -fn sparse_places_only_listed_cells() { - // A sparse grid holding just the player and one object; every other cell empty. - let toml = r##" -[map] -name = "Test" -width = 4 -height = 1 - -[grid] -sparse = [ { x = 0, y = 0, ch = "@" }, { x = 2, y = 0, ch = "O" } ] -palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } } -"##; - let board = load_board(toml); - assert!(board.is_valid()); - assert_eq!((board.player.x, board.player.y), (0, 0)); - assert_eq!(board.objects.len(), 1); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (2, 0)); - // An unlisted sparse cell is a transparent empty. - assert_eq!(board.get((1, 0)).1, Archetype::Empty); -} - -#[test] -fn sparse_out_of_bounds_cell_is_nonfatal() { - let toml = r##" -[map] -name = "Test" -width = 2 -height = 1 - -[grid] -sparse = [ { x = 5, y = 0, ch = "O" }, { x = 0, y = 0, ch = "@" } ] -palette = { " " = { kind = "empty" }, "@" = { kind = "player" }, "O" = { kind = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } } -"##; - let board = load_board(toml); - assert!( - board.objects.is_empty(), - "out-of-bounds object cell is skipped" - ); - assert!( - !board.is_valid(), - "out-of-bounds sparse cell is a nonfatal error" - ); - assert_eq!((board.player.x, board.player.y), (0, 0)); -} - -#[test] -fn fill_must_be_a_single_char() { - // A multi-char fill falls back to a space (nonfatal); spaces resolve to empty. - let toml = r##" -[map] -name = "Test" -width = 2 -height = 1 - -[grid] -fill = "xy" -palette = { " " = { kind = "empty" } } -"##; - let board = load_board(toml); - assert!( - !board.is_valid(), - "a non-single-char fill is a nonfatal error" - ); - assert_eq!(board.get((1, 0)).1, Archetype::Empty); -} diff --git a/kiln-core/src/tests/map_file/grid_errors.rs b/kiln-core/src/tests/map_file/grid_errors.rs deleted file mode 100644 index 7205e59..0000000 --- a/kiln-core/src/tests/map_file/grid_errors.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::{grid, load_board, map}; -use crate::builtin::Archetype; -use crate::board::Board; -use crate::map_file::MapFile; - -#[test] -fn grid_wrong_row_count_returns_error() { - // height = 3 but only 2 rows in the layer grid. - let toml = map(3, 3, &grid("...\n...", &[(".", "kind = \"empty\"")])); - let mf: MapFile = toml::from_str(&toml).unwrap(); - let result = Board::try_from(mf); - assert!(result.is_err()); - let msg = result.err().unwrap(); - assert!( - msg.contains("2 rows"), - "expected row count in error, got: {msg}" - ); - assert!( - msg.contains("3 tall"), - "expected declared height in error, got: {msg}" - ); -} - -#[test] -fn grid_wrong_row_width_returns_error() { - // width = 4 but second row is only 3 characters. - let toml = map(4, 2, &grid("....\n...", &[(".", "kind = \"empty\"")])); - let mf: MapFile = toml::from_str(&toml).unwrap(); - let result = Board::try_from(mf); - assert!(result.is_err()); - let msg = result.err().unwrap(); - assert!( - msg.contains("3 characters"), - "expected col count in error, got: {msg}" - ); - assert!( - msg.contains("4 wide"), - "expected declared width in error, got: {msg}" - ); -} - -#[test] -fn unknown_kind_produces_error_block() { - // A palette `kind` that is neither a meta-kind nor a known archetype name - // becomes a visible ErrorBlock. Player placed away from the X cell. - let toml = map( - 2, - 1, - &grid( - "X@", - &[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")], - ), - ); - let board = load_board(&toml); - assert_eq!( - *board.get((0, 0)), - (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock) - ); -} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs deleted file mode 100644 index bf5a3be..0000000 --- a/kiln-core/src/tests/map_file/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod fill_sparse; -mod grid_errors; -mod object_placement; -mod player_placement; -mod portal_placement; -mod pushers; -mod round_trip; -mod spinners; -mod transporters; - -use crate::board::Board; -use crate::map_file::MapFile; - -fn load_board(toml: &str) -> Board { - let mf: MapFile = toml::from_str(toml).expect("parse toml"); - Board::try_from(mf).expect("convert to board") -} - -/// Builds the `[grid]` block: a triple-quoted `content` grid plus an inline -/// `palette` table. Each palette entry is `(char_key, inline-body)` where the -/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`). -fn grid(content: &str, palette: &[(&str, &str)]) -> String { - let pal = palette - .iter() - .map(|(k, body)| format!("\"{k}\" = {{ {body} }}")) - .collect::>() - .join(", "); - format!("\n[grid]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n") -} - -/// Wraps a `[map]` header of the given size around the single `[grid]` block -/// (produced by [`grid`]). -fn map(width: usize, height: usize, grid_block: &str) -> String { - format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n{grid_block}") -} - -/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry -/// placed by char `ch` (cyan `@` glyph), with `extra` appended to its body. The -/// player is placed at the far-right cell via a second char where room allows; -/// callers that need the player elsewhere build the map directly. -fn map_3x1_object(grid_str: &str, ch: &str, extra: &str) -> String { - let body = if extra.is_empty() { - "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string() - } else { - format!("kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\", {extra}") - }; - map( - 3, - 1, - &grid( - grid_str, - &[ - (" ", "kind = \"empty\""), - (".", "kind = \"empty\""), - ( - "#", - "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"", - ), - ("@", "kind = \"player\""), - (ch, &body), - ], - ), - ) -} diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs deleted file mode 100644 index 0ec75c8..0000000 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::{grid, load_board, map, map_3x1_object}; -use crate::builtin::Archetype; - -/// Palette shorthand. -const PLAYER: (&str, &str) = ("@", "kind = \"player\""); - -/// An object palette entry body with the given `extra` fields appended. -fn obj(extra: &str) -> String { - let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\""; - if extra.is_empty() { - base.to_string() - } else { - format!("{base}, {extra}") - } -} - -#[test] -fn duplicate_name_clears_second_but_keeps_both_objects() { - // Two object entries share the name "gate": first keeps it, second is cleared. - let board = load_board(&map( - 3, - 1, - &grid( - "GH@", - &[ - ("G", &obj("name = \"gate\"")), - ("H", &obj("name = \"gate\", solid = false")), - PLAYER, - ], - ), - )); - assert_eq!(board.objects.len(), 2, "both objects survive"); - assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("gate")); - assert_eq!(board.objects[&2].scripting.name, None); - assert!(!board.is_valid(), "duplicate name is a nonfatal load error"); -} - -#[test] -fn palette_placement_puts_object_on_empty_floor() { - // `G` lands an object; its cell stays Empty (transparent). - let board = load_board(&map_3x1_object("G.@", "G", "")); - assert_eq!(board.objects.len(), 1); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - assert_eq!(board.get((0, 0)).1, Archetype::Empty); -} - -#[test] -fn palette_char_appearing_twice_spawns_two_objects() { - // `G` appears at (0,0) and (1,0); two independent ObjectDefs are spawned. - let board = load_board(&map_3x1_object("GG@", "G", "solid = false")); - assert_eq!(board.objects.len(), 2); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0)); - assert!(board.is_valid(), "multiple occurrences are not an error"); -} - -#[test] -fn palette_char_multi_occurrence_only_first_keeps_name() { - // Three occurrences of a named entry: the first keeps the name, the rest don't. - let board = load_board(&map( - 4, - 1, - &grid( - "GGG@", - &[("G", &obj("solid = false, name = \"guard\"")), PLAYER], - ), - )); - assert_eq!(board.objects.len(), 3); - assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("guard")); - assert_eq!(board.objects[&2].scripting.name, None); - assert_eq!(board.objects[&3].scripting.name, None); -} - -#[test] -fn non_solid_object_and_wall_coexist_in_separate_cells() { - // With one grid each cell holds a single palette char, so a solid object can - // never be authored onto a wall cell. A wall and a (separate-cell) non-solid - // object both load fine. - let board = load_board(&map_3x1_object("#G@", "G", "solid = false")); - assert_eq!(board.objects.len(), 2); // The wall (for now) shows up as an object because it's a builtin, TODO - assert!(board.is_valid()); -} diff --git a/kiln-core/src/tests/map_file/player_placement.rs b/kiln-core/src/tests/map_file/player_placement.rs deleted file mode 100644 index 3154cff..0000000 --- a/kiln-core/src/tests/map_file/player_placement.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::{grid, load_board, map}; -use crate::builtin::Archetype; - -/// Palette shorthands shared by these tests. -const EMPTY: (&str, &str) = (".", "kind = \"empty\""); -const PLAYER: (&str, &str) = ("@", "kind = \"player\""); -const WALL: (&str, &str) = ( - "#", - "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"", -); - -#[test] -fn player_char_places_on_empty_floor() { - let b = load_board(&map(3, 1, &grid(".@.", &[EMPTY, PLAYER]))); - assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get((1, 0)).1, Archetype::Empty); - assert!(b.is_valid()); -} - -#[test] -fn player_char_appearing_twice_uses_first() { - let b = load_board(&map(3, 1, &grid("@.@", &[EMPTY, PLAYER]))); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(!b.is_valid()); -} - -#[test] -fn player_char_missing_falls_back_to_origin() { - let b = load_board(&map(3, 1, &grid("...", &[EMPTY]))); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(!b.is_valid()); -} - -#[test] -fn player_fallback_to_origin_clears_solid_terrain() { - // No player cell, so the player falls back to (0, 0) — which holds a wall. The - // player wins its cell: the wall is cleared. The fallback is still reported. - let b = load_board(&map(3, 1, &grid("#..", &[WALL, EMPTY]))); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get((0, 0)).1, Archetype::Empty); - assert!(!b.is_valid()); -} - -#[test] -fn player_fallback_wins_against_a_solid_object() { - // No player cell → player falls back to (0, 0), which holds a solid object. The - // player wins its cell and the object is dropped. - let b = load_board(&map( - 3, - 1, - &grid( - "O..", - &[ - EMPTY, - ( - "O", - "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"", - ), - ], - ), - )); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(b.objects.is_empty()); -} diff --git a/kiln-core/src/tests/map_file/portal_placement.rs b/kiln-core/src/tests/map_file/portal_placement.rs deleted file mode 100644 index d9d5325..0000000 --- a/kiln-core/src/tests/map_file/portal_placement.rs +++ /dev/null @@ -1,56 +0,0 @@ -use super::{grid, load_board, map}; - -const EMPTY: (&str, &str) = (".", "kind = \"empty\""); -const PLAYER: (&str, &str) = ("@", "kind = \"player\""); - -#[test] -fn portal_duplicate_name_drops_second() { - // Two portal cells share the name "a": the second is dropped. - let board = load_board(&map( - 3, - 1, - &grid( - "12@", - &[ - ( - "1", - "kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"b\"", - ), - ( - "2", - "kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"c\"", - ), - PLAYER, - ], - ), - )); - assert_eq!( - board.portals.len(), - 1, - "second portal with duplicate name should be dropped" - ); - assert_eq!(board.portals[0].x, 0); - assert!(!board.is_valid(), "duplicate name should be a load error"); -} - -#[test] -fn portal_palette_char_places_portal_at_grid_position() { - let board = load_board(&map( - 3, - 1, - &grid( - "1.@", - &[ - ( - "1", - "kind = \"portal\", name = \"p\", target_map = \"x\", target_entry = \"e\"", - ), - EMPTY, - PLAYER, - ], - ), - )); - assert_eq!(board.portals.len(), 1); - assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0)); - assert_eq!(board.portals[0].name, "p"); -} diff --git a/kiln-core/src/tests/map_file/pushers.rs b/kiln-core/src/tests/map_file/pushers.rs deleted file mode 100644 index f95e232..0000000 --- a/kiln-core/src/tests/map_file/pushers.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects -//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_` tag). - -use super::{grid, load_board, map}; -use crate::builtin::Archetype; -use crate::game::GameState; -use crate::map_file::MapFile; -use crate::object_def::ObjectDef; -use std::time::Duration; -use crate::Board; - -/// Finds the pusher object on a board (by its built-in tag). -fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef { - let (&oid, obj) = board - .objects - .iter() - .find(|(_, o)| o.scripting.tags.contains("BUILTIN_pusher_east")) - .expect("pusher object"); - *id = oid; - obj -} - -#[test] -fn pusher_loads_as_a_tagged_scripted_solid_object() { - let board = load_board(&map( - 3, - 1, - &grid( - "P @", - &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], - ), - )); - let mut id = 0; - let p = pusher(&board, &mut id); - assert_eq!((p.x, p.y), (0, 0)); - assert!( - p.scripting.builtin_script.is_some(), - "carries the embedded pusher script" - ); - // Each alias gets its own compile-cache key (e.g. "BUILTIN_pusher_east") so the - // script can read direction from Me.has_tag("BUILTIN_pusher_east"). - assert_eq!(p.scripting.script_name.as_deref(), Some("BUILTIN_pusher_east")); - assert!(!board.is_passable(0, 0), "pusher is solid"); -} - -fn is_tag(board: &Board, x: usize, y: usize, tag: &str) -> bool { - board.object_ids_at(x, y).iter().any(|id| board.objects[id].scripting.tags.contains(tag)) -} - -#[test] -fn pusher_advances_and_shoves_a_crate() { - // Pusher at (0,0), crate at (1,0), the player parked at the east edge. - let board = load_board(&map( - 5, - 1, - &grid( - "Po @", - &[ - ("P", "kind = \"pusher_east\""), - ("o", "kind = \"crate\""), - ("@", "kind = \"player\""), - ], - ), - )); - let mut pid = 0; - pusher(&board, &mut pid); - - let mut game = GameState::new(board); - game.run_init(); - for _ in 0..12 { - game.tick(Duration::from_secs_f64(0.3)); - } - - let b = game.board(); - assert!(b.objects[&pid].x > 0, "pusher advanced east"); - assert_eq!( - b.get((1, 0)).1, - Archetype::Empty, - "crate left its start cell" - ); - assert!( - (2..b.width).any(|x| is_tag(&b, x, 0, "BUILTIN_crate")), - "crate was shoved east" - ); -} - -#[test] -fn pusher_blocked_by_wall_stays_put() { - let board = load_board(&map( - 4, - 1, - &grid( - "P# @", - &[ - ("P", "kind = \"pusher_east\""), - ("#", "kind = \"wall\""), - ("@", "kind = \"player\""), - ], - ), - )); - let mut pid = 0; - pusher(&board, &mut pid); - - let mut game = GameState::new(board); - game.run_init(); - for _ in 0..12 { - game.tick(Duration::from_secs_f64(0.3)); - } - assert_eq!( - game.board().objects[&pid].x, - 0, - "a wall-blocked pusher does not move" - ); -} - -#[test] -fn pusher_round_trips_to_its_keyword() { - let board = load_board(&map( - 3, - 1, - &grid( - "P @", - &[("P", "kind = \"pusher_east\""), ("@", "kind = \"player\"")], - ), - )); - // Save collapses the expanded object back into the `pusher_east` keyword. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - toml_out.contains("pusher_east"), - "pusher should save as its archetype keyword, got:\n{toml_out}" - ); - - // Reload: it is a working pusher object again. - let board2 = load_board(&toml_out); - let mut id = 0; - let p = pusher(&board2, &mut id); - assert_eq!((p.x, p.y), (0, 0)); - assert!(p.scripting.builtin_script.is_some()); -} diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs deleted file mode 100644 index d1ce18e..0000000 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ /dev/null @@ -1,157 +0,0 @@ -use super::{grid, load_board, map}; -use crate::glyph::Glyph; -use crate::map_file::{MapFile, parse_color}; -use color::Rgba8; - -const EMPTY: (&str, &str) = (".", "kind = \"empty\""); -const PLAYER: (&str, &str) = ("@", "kind = \"player\""); - -/// An object palette entry body with the given `extra` fields appended. -fn obj(extra: &str) -> String { - let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\""; - if extra.is_empty() { - base.to_string() - } else { - format!("{base}, {extra}") - } -} - -/// Round-trips a board through save→load and returns the reloaded board. -fn round_trip(toml: &str) -> crate::board::Board { - let board = load_board(toml); - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - load_board(&toml_out) -} - -#[test] -fn object_glyph_round_trips_through_toml() { - let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); - let board = load_board(&toml); - assert_eq!(board.objects.len(), 1); - let obj0 = &board.objects[&1]; - assert_eq!((obj0.x, obj0.y), (1, 0)); - assert_eq!(obj0.glyph.tile, 64); - assert_eq!( - obj0.glyph.fg, - Rgba8 { - r: 0x00, - g: 0xFF, - b: 0xFF, - a: 255 - } - ); - - let board2 = round_trip(&toml); - let obj2 = &board2.objects[&1]; - assert_eq!(obj2.glyph.tile, obj0.glyph.tile); - assert_eq!(obj2.glyph.fg, obj0.glyph.fg); - assert_eq!(obj2.glyph.bg, obj0.glyph.bg); -} - -#[test] -fn object_tags_round_trip_through_toml() { - let toml = map( - 3, - 1, - &grid( - "@O.", - &[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY], - ), - ); - let board = load_board(&toml); - let obj0 = &board.objects[&1]; - assert!(obj0.scripting.tags.contains("enemy") && obj0.scripting.tags.contains("boss")); - assert_eq!(obj0.scripting.tags.len(), 2); - - // Saved tags must be sorted alphabetically (boss before enemy). - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - let boss = toml_out.find("\"boss\"").expect("boss in TOML"); - let enemy = toml_out.find("\"enemy\"").expect("enemy in TOML"); - assert!(boss < enemy, "tags must be sorted: boss before enemy"); - - let board2 = load_board(&toml_out); - assert_eq!(board2.objects[&1].scripting.tags, obj0.scripting.tags); -} - -#[test] -fn object_empty_tags_omitted_from_toml() { - let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); - let board = load_board(&toml); - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - !toml_out.contains("tags"), - "empty tags must not appear in TOML output" - ); -} - -#[test] -fn object_name_round_trips_through_toml() { - let toml = map( - 3, - 1, - &grid( - "@O.", - &[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY], - ), - ); - let board = load_board(&toml); - assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("beacon")); - - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - toml_out.contains("\"beacon\""), - "name must appear in saved TOML" - ); - let board2 = load_board(&toml_out); - assert_eq!(board2.objects[&1].scripting.name.as_deref(), Some("beacon")); -} - -#[test] -fn unnamed_object_name_stays_none_through_toml() { - let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); - let board2 = round_trip(&toml); - assert_eq!( - board2.objects[&1].scripting.name, None, - "unnamed object must round-trip as None" - ); -} - -#[test] -fn fixed_floor_attribute_round_trips_through_toml() { - // A fixed floor glyph is now a board attribute (`floor = { … }`), not a grid - // cell. It must survive save→load and show through empty grid cells. - let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\ - floor = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n\ - [grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\ - palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n"; - let fixed = Glyph { - tile: '#' as u32, - fg: parse_color("#010203"), - bg: parse_color("#040506"), - }; - let board = load_board(toml); - // An empty grid cell reveals the fixed floor. - assert_eq!(board.glyph_at((1, 0)), fixed); - - let board2 = round_trip(toml); - assert_eq!(board2.glyph_at((1, 0)), fixed); -} - -#[test] -fn biome_floor_round_trips_generator_name() { - // A biome floor re-emits its generator name (not baked glyphs), so the save - // stays compact and the reloaded board is identical. - let toml = "[map]\nname = \"Test\"\nwidth = 3\nheight = 1\n\ - floor = { generator = \"grass\" }\n\ - [grid]\ncontent = \"\"\"\n@..\n\"\"\"\n\ - palette = { \"@\" = { kind = \"player\" }, \".\" = { kind = \"empty\" } }\n"; - let board = load_board(toml); - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - toml_out.contains("generator = \"grass\""), - "biome floor must re-emit its generator name, got: {toml_out}" - ); - // Reloaded floor glyphs match (deterministic seed). - let board2 = load_board(&toml_out); - assert_eq!(board2.glyph_at((1, 0)), board.glyph_at((1, 0))); -} diff --git a/kiln-core/src/tests/map_file/spinners.rs b/kiln-core/src/tests/map_file/spinners.rs deleted file mode 100644 index 4110846..0000000 --- a/kiln-core/src/tests/map_file/spinners.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Spinners are scripted objects: the `spinner_cw`/`spinner_ccw` archetypes expand -//! into objects carrying the embedded `spinner.rhai` plus a `BUILTIN_spinner_` -//! tag, and collapse back to the keyword on save (just like pushers). - -use super::{grid, load_board, map}; -use crate::map_file::MapFile; - -#[test] -fn spinner_loads_as_a_tagged_scripted_solid_object() { - let board = load_board(&map( - 3, - 1, - &grid( - "S @", - &[("S", "kind = \"spinner_cw\""), ("@", "kind = \"player\"")], - ), - )); - let (_, obj) = board - .objects - .iter() - .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_cw")) - .expect("spinner object"); - assert_eq!((obj.x, obj.y), (0, 0)); - assert!( - obj.scripting.builtin_script.is_some(), - "carries the embedded spinner script" - ); - // Each alias gets its own compile-cache key (e.g. "BUILTIN_spinner_cw") so the - // script can read direction from Me.has_tag("BUILTIN_spinner_cw"). - assert_eq!(obj.scripting.script_name.as_deref(), Some("BUILTIN_spinner_cw")); - assert!(!board.is_passable(0, 0), "spinner is solid"); -} - -#[test] -fn spinner_round_trips_to_its_keyword() { - let board = load_board(&map( - 3, - 1, - &grid( - "S @", - &[("S", "kind = \"spinner_ccw\""), ("@", "kind = \"player\"")], - ), - )); - // Save collapses the expanded object back into the `spinner_ccw` keyword. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - toml_out.contains("spinner_ccw"), - "spinner should save as its archetype keyword, got:\n{toml_out}" - ); - - // Reload: it is a working spinner object again, still counter-clockwise. - let board2 = load_board(&toml_out); - let (_, obj) = board2 - .objects - .iter() - .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_ccw")) - .expect("spinner object after round-trip"); - assert_eq!((obj.x, obj.y), (0, 0)); - assert!(obj.scripting.builtin_script.is_some()); -} diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs deleted file mode 100644 index 78b2365..0000000 --- a/kiln-core/src/tests/map_file/transporters.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Transporters are scripted objects (the `transporter_*` archetypes expand into -//! objects carrying the embedded `transporter.rhai` plus a -//! `BUILTIN_transporter_` tag). Bumping one from its facing side teleports -//! the bumper past it, or out of a paired opposite-facing transporter. - -use super::{grid, load_board, map}; -use crate::game::GameState; -use crate::map_file::MapFile; -use crate::utils::Direction; -use std::time::Duration; - -#[test] -fn transporter_loads_as_a_tagged_scripted_object() { - let board = load_board(&map( - 3, - 1, - &grid( - "@T ", - &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - ), - )); - let (_, obj) = board - .objects - .iter() - .find(|(_, o)| o.scripting.tags.contains("BUILTIN_transporter_east")) - .expect("transporter object"); - assert_eq!((obj.x, obj.y), (1, 0)); - assert!(obj.scripting.builtin_script.is_some(), "carries the embedded script"); -} - -#[test] -fn bumping_a_transporter_drops_you_on_its_far_side() { - // Player at (0,0), an east transporter at (1,0), and a free cell at (2,0). - let board = load_board(&map( - 3, - 1, - &grid( - "@T ", - &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - ), - )); - let mut game = GameState::new(board); - game.run_init(); - - // Walk east into the transporter: it's solid so the player doesn't step onto - // it, but the bump fires a teleport that resolves within this try_move. The - // trailing tick just advances the transporter's idle animation. - game.try_move(Direction::East); - game.tick(Duration::from_secs_f64(0.1)); - - let b = game.board(); - assert_eq!((b.player.x, b.player.y), (2, 0), "transported past the transporter"); -} - -#[test] -fn a_blocked_far_side_transports_out_of_the_paired_transporter() { - // Player, east transporter, a wall blocking its far side, a gap, the paired - // west transporter, then a free cell. The player should pop out on the paired - // transporter's far side (the cell just past it), across the wall. - let board = load_board(&map( - 6, - 1, - &grid( - "@T# W ", - &[ - ("@", "kind = \"player\""), - ("T", "kind = \"transporter_east\""), - ("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""), - ("W", "kind = \"transporter_west\""), - ], - ), - )); - let mut game = GameState::new(board); - game.run_init(); - - game.try_move(Direction::East); - game.tick(Duration::from_secs_f64(0.1)); - - let b = game.board(); - assert_eq!( - (b.player.x, b.player.y), - (5, 0), - "transported past the paired transporter", - ); -} - -#[test] -fn a_pushed_crate_is_transported_through() { - // Player, a crate, an east transporter, then a free cell. Pushing the crate - // east into the transporter bumps it (through the crate chain); the transporter - // moves the crate — a plain terrain solid, no object id — out its far side. - let board = load_board(&map( - 4, - 1, - &grid( - "@oT ", - &[ - ("@", "kind = \"player\""), - ("o", "kind = \"crate\""), - ("T", "kind = \"transporter_east\""), - ], - ), - )); - let mut game = GameState::new(board); - game.run_init(); - - // Push east into the crate: the crate can't move (the transporter blocks it), - // but the transporter is bumped from the West and teleports the crate to (3,0). - game.try_move(Direction::East); - game.tick(Duration::from_secs_f64(0.1)); - { - let b = game.board(); - assert!(b.solid_at(3, 0).is_some(), "crate transported to the far side"); - assert!(b.is_passable(1, 0), "crate's old cell is now empty"); - assert_eq!((b.player.x, b.player.y), (0, 0), "player didn't move yet"); - } - - // With the crate gone, a second push walks the player onto the vacated cell. - game.try_move(Direction::East); - game.tick(Duration::from_secs_f64(0.1)); - let b = game.board(); - assert_eq!((b.player.x, b.player.y), (1, 0), "player follows into the gap"); -} - -#[test] -fn transporter_round_trips_to_its_keyword() { - let board = load_board(&map( - 3, - 1, - &grid( - "@T ", - &[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")], - ), - )); - // Save collapses the expanded object back into the `transporter_east` keyword. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - assert!( - toml_out.contains("transporter_east"), - "transporter should save as its archetype keyword, got:\n{toml_out}" - ); - - // Reload: it is a working transporter object again. - let board2 = load_board(&toml_out); - assert!( - board2 - .objects - .values() - .any(|o| o.scripting.tags.contains("BUILTIN_transporter_east") && o.scripting.builtin_script.is_some()), - "reloads as a tagged transporter object", - ); -} diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index feda5a7..0fb0781 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -1,7 +1,5 @@ mod actions; mod game_portals; -// TODO(migration): the map_file suite predates BoardSpec and is not ported yet. -// mod map_file; mod movement; mod scripting; diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 6216b0e..b67b404 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -226,7 +226,7 @@ impl From for (i64, i64) { #[derive(Copy,Clone,Debug, PartialEq)] pub enum Hook { - Init, Tick, Bump, Grab, Enter + Init, Tick, Bump, Enter } impl Hook { @@ -234,7 +234,6 @@ impl Hook { match self { Hook::Init => "init", Hook::Bump => "bump", - Hook::Grab => "grab", Hook::Tick => "tick", Hook::Enter => "enter" }