wip 7 more test fixing
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+200
-8
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
@@ -223,4 +231,188 @@ impl BoardSpec {
|
||||
|
||||
Ok(board)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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<Board, String> {
|
||||
let spec: BoardSpec = toml::from_str(toml_src).expect("board table parses");
|
||||
let owned: Vec<String> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -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));
|
||||
|
||||
+2
-17
@@ -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<BoardAction> {
|
||||
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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_<colour>` 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.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),
|
||||
],
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
//! Pushers are now scripted objects (the `pusher_*` archetypes expand into objects
|
||||
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` 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());
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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_<dir>`
|
||||
//! 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());
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Transporters are scripted objects (the `transporter_*` archetypes expand into
|
||||
//! objects carrying the embedded `transporter.rhai` plus a
|
||||
//! `BUILTIN_transporter_<dir>` 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",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ impl From<Direction> 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user