From ba8c72d5a54341a3b6e714ebe1823dde7b07107d Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 25 Jul 2026 23:20:52 -0500 Subject: [PATCH] more test cleanup --- kiln-core/src/action.rs | 11 ----- kiln-core/src/api/object_info.rs | 16 +------ kiln-core/src/api/player.rs | 4 +- kiln-core/src/board.rs | 32 -------------- kiln-core/src/board_spec.rs | 10 ++--- kiln-core/src/game.rs | 49 ++++----------------- kiln-core/src/script.rs | 57 +++++++++++++----------- kiln-core/src/tests/actions.rs | 3 -- kiln-core/src/tests/mod.rs | 2 +- kiln-core/src/tests/scripting.rs | 9 ++-- kiln-core/src/tile.rs | 14 ++++-- kiln-tui/src/render.rs | 74 +++++++++++++++++++++++--------- 12 files changed, 117 insertions(+), 164 deletions(-) diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index fc656e7..34d7857 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -162,17 +162,6 @@ pub struct BoardAction { pub(crate) action: Action, } -#[derive(Clone, Debug, PartialEq)] -pub enum Consequence { - Enter(ObjectId), - Bump(ObjectId, Direction), - Send(ObjectId, String, SendArg) -} - -fn enters_for_cell(board: &Board, x: usize, y: usize) -> Vec { - board.sensor_ids_at(x, y).into_iter().map(|id| Consequence::Enter(id)).collect() -} - pub fn apply_teleport(board: &mut Board, target: i64, x: i64, y: i64) -> Result<(), String> { if !board.in_bounds((x, y)) { Err(format!("teleport({target},{x},{y}): out of bounds")) diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs index 7b95e61..85e3ddb 100644 --- a/kiln-core/src/api/object_info.rs +++ b/kiln-core/src/api/object_info.rs @@ -65,17 +65,6 @@ impl ObjectInfo { } } - pub fn from_def(obj: &ObjectDef, board: BoardRef, x: usize, y: usize) -> ObjectInfo { - Self { - id: obj.scripting.id, - x: x as i64, - y: y as i64, - board: board.clone(), - script_key: obj.scripting.script_name.clone(), - queue: obj.scripting.queue.clone() - } - } - pub fn drain(&mut self, target: &mut Vec, dt: f64) { if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) { scr.queue.drain(self.id, target, dt) @@ -140,9 +129,8 @@ impl Registerable for ObjectInfo { engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool { let board = o.board.borrow(); - let tx = o.x + dir.dx(); - let ty = o.y + dir.dy(); - board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize) + let (tx, ty) = dir.from_point(o.x, o.y); + !board.in_bounds((tx, ty)) || !board.is_passable(tx as usize, ty as usize) }); } } \ No newline at end of file diff --git a/kiln-core/src/api/player.rs b/kiln-core/src/api/player.rs index 9773858..34791f3 100644 --- a/kiln-core/src/api/player.rs +++ b/kiln-core/src/api/player.rs @@ -16,7 +16,7 @@ impl Registerable for PlayerWithPos { .register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health) .register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_health) .register_get("keys", |player: &mut PlayerWithPos| player.0.borrow().keys) - .register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0) - .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player_pos().1); + .register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0 as i64) + .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player_pos().1 as i64); } } diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index c9c0702..3290348 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -266,38 +266,6 @@ impl Board { } } - /// The object that a move **into** `(x, y)` heading `dir` bumps, if any. - /// - /// Walks the chain of pushable crates from the target cell in `dir` until it - /// reaches something that stops it, and reports the object it presses against: - /// - a `Block` object in the target cell (or at the end of a pushable chain) is the - /// bumped object, - /// - open space or the player means nothing is bumped, - /// - /// This is what lets *any* solid — the player, another object, or a pushed - /// crate — trigger a `bump`: the bumper need not be an object, since we only - /// return the *bumped* object's id (the direction it came from is supplied by - /// the caller from its move direction). - pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option { - let (dx, dy): (i64, i64) = dir.into(); - let (mut cx, mut cy) = (x, y); - loop { - if !self.in_bounds((cx as i64, cy as i64)) { - return None; // push chain runs off the board - } - - if let Some(Tile::Object(obj)) = self.get(cx, cy) { - if obj.enter_response.transmits_push(dir) { - // An object we can push through, go to the next cell - cx = (cx as i64 + dx) as usize; - cy = (cy as i64 + dy) as usize; - } else if obj.enter_response.bumpable(dir) { - return Some(obj.scripting.id) - } - } - } - } - /// Whether the chain of pushable solids starting at `(x, y)` can be shoved one /// step in `dir` — i.e. the chain ends at a passable cell rather than the board /// edge or a non-pushable solid. diff --git a/kiln-core/src/board_spec.rs b/kiln-core/src/board_spec.rs index 42f976f..b64bc61 100644 --- a/kiln-core/src/board_spec.rs +++ b/kiln-core/src/board_spec.rs @@ -94,7 +94,7 @@ impl BoardSpec { /// /// Returns `Err` only on a grid-dimension mismatch (the single hard error); /// every other problem is recorded on `errors`. - pub(crate) fn build_grid(&self) -> Result>, String> { + pub fn build_grid(&self) -> Result>, String> { let grid = self.grid_chars()?; // Walk the grid, filling cells and collecting placements. @@ -116,7 +116,7 @@ impl BoardSpec { } /// Try to check for validity of the board, return a list of errors we find (if any) - pub(crate) fn validate(&self, grid: &Vec>, valid_script_names: &HashSet<&String>) -> Result<(), Vec> { + pub fn validate(&self, grid: &Vec>, valid_script_names: &HashSet<&String>) -> Result<(), Vec> { let mut errors = vec![]; // Check for player being positioned exactly once @@ -188,13 +188,13 @@ impl BoardSpec { if errors.is_empty() { Ok(()) } else { Err(errors) } } - pub(crate) fn into_board(self, script_names: &HashSet<&String>) -> Result { + pub fn into_board(self, script_names: &HashSet<&String>) -> Result { let grid = self.build_grid()?; if let Err(errors) = self.validate(&grid, script_names) { return Err(errors.join("\n")); } - let mut next_object_id = 0; + let mut next_object_id = 1; let mut tile_grid = Vec::with_capacity(grid.len()); for spec in grid.into_iter() { @@ -216,7 +216,7 @@ impl BoardSpec { floor: self.floor.into_floor(self.width, self.height), sensors, portals: self.portals, - next_object_id: 0, + next_object_id, dark: self.dark, registry: Default::default(), }; diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 36de6ed..8d5bf25 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,4 +1,4 @@ -use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, Consequence, SendArg}; +use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, SendArg}; use crate::board::Board; use crate::log::LogLine; use crate::script::ScriptHost; @@ -9,32 +9,6 @@ use std::collections::{BTreeSet, HashSet, VecDeque}; use std::hash::Hash; use std::time::Duration; -/// A single `send` to an object, with an arg. -/// -/// Most things (bump, etc) are only triggered by player actions now. -/// However, sends still might trigger other sends! So we need to keep a -/// list of sends that we trigger in the process of resolving a list of -/// actions. When we resolve these sends, we'll keep a list of things we've -/// resolved, so we refuse to do the same send twice in a tick: this prevents -/// us from accidentally doing an infinite recursion. -#[derive(Clone, Debug)] -struct SendAction(ObjectId, String, SendArg); - -impl Hash for SendAction { - fn hash(&self, state: &mut H) { - self.0.hash(state); - self.1.hash(state); - } -} - -impl PartialEq for SendAction { - fn eq(&self, other: &Self) -> bool { - self.1 == other.1 && self.0 == other.0 - } -} - -impl Eq for SendAction {} - /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; @@ -43,7 +17,7 @@ pub const SAY_DURATION: f64 = 3.0; pub use crate::action::ScrollLine; use crate::player::{Player, PlayerRef}; use crate::portal::Portal; -use crate::tile::{EnterResponse, LocatedObject, Tile}; +use crate::tile::{EnterResponse, Tile}; /// An active scroll overlay opened by a scripted object via `scroll()`. /// @@ -358,7 +332,7 @@ impl GameState { { // Dispatch the choice back to the source object and apply whatever it // queues (plus any bump/send cascade), the same as a tick. - let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None); + self.scripts.run_send(scroll.source, &choice, SendArg::None); } } @@ -391,8 +365,12 @@ impl GameState { // Clear per-board transient state. self.speech_bubbles.clear(); self.active_scroll = None; - // Switch to the new board and place the player at the arrival portal. + // Switch to the new board self.current_board_name = target_map.to_string(); + // Clear any player instance that's already on the new board + let new_board_player_pos = self.board().player_pos(); + self.board_mut().get_mut(new_board_player_pos.0, new_board_player_pos.1).take(); + // place the player at the arrival portal. self.board_mut().get_mut(ax, ay).replace(Tile::Player); self.board_mut().clear_all_queues(); // Rebuild the script host for the new board's objects. @@ -522,17 +500,6 @@ impl GameState { } } -/// What a [`step_object`] call produced, for the caller to resolve after the board -/// borrow drops. -struct StepOutcome { - /// The solid object the move pressed into (directly or at a crate-chain's end), - /// which receives a `bump`. - bumped: Option, - /// Non-solid objects a solid landed on this move — the mover itself (only if it - /// is solid) and every crate it shoved — each of which receives an `enter`. - entered: Vec, -} - /// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter` /// reactions for the caller to resolve after the board borrow drops. /// diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index fd52497..eb46a38 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -135,34 +135,39 @@ impl ScriptHost { continue; } - if let Some(source) = script_key.source(&script_sources) { - match engine.compile(source) { - Ok(ast) => { - let defines = |n: &str, params: usize| { - ast.iter_functions() - .any(|f| f.name == n && f.params.len() == params) - }; - scripts.insert( - script_key.clone(), - CompiledScript { - 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, - }, - ); - } - Err(err) => { // It didn't compile... - failed.insert(script_key.clone()); - log_sink.error(format!("script '{}' failed to compile: {err}", script_key.name())); - continue; + match script_key.source(&script_sources) { + Ok(None) => { continue } // This has no source... + Err(e) => { // Couldn't find it + failed.insert(script_key.clone()); + log_sink.error(e); + continue; + } + Ok(Some(source)) => { + match engine.compile(source) { + Ok(ast) => { + let defines = |n: &str, params: usize| { + ast.iter_functions() + .any(|f| f.name == n && f.params.len() == params) + }; + scripts.insert( + script_key.clone(), + CompiledScript { + 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, + }, + ); + } + Err(err) => { // It didn't compile... + failed.insert(script_key.clone()); + log_sink.error(format!("script '{}' failed to compile: {err}", script_key.name())); + continue; + } } } - } else { - // This has no source... - continue; } } diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 9aa07ff..9354b97 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -203,6 +203,3 @@ fn blocked_reports_solid_and_clear() { game.run_init(); assert_eq!(glyph(&game, id).tile, 7); } - - - diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index f824cb0..604ffe8 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -1,6 +1,6 @@ mod actions; // TODO(migration): map_file is not yet ported — it is blocked on the map files -// themselves still being pre-BoardSpec (see todo.md #5). +// themselves still being pre-BoardSpec (see todo.md #1). mod game_portals; // mod map_file; mod movement; diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index b043e04..9034a5b 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -92,8 +92,9 @@ fn missing_hooks_and_no_script_are_noops() { #[test] fn compile_and_unknown_script_errors_are_logged() { - // A reference to a script name that isn't in the pool. See todo.md #5: this is - // currently a silent `continue` in ScriptHost::new, so nothing is logged. + // A reference to a script name that isn't in the world pool. `ScriptKey::None` + // (an object with no script at all) must stay silent; only a named script that + // can't be resolved is an error. let (board, scripts, _) = board_with_object(Some("ghost"), &[]); let game = GameState::with_scripts(board, scripts); assert!( @@ -149,7 +150,7 @@ fn start_map_greeter_runs_init() { // Keep the failure message short: a TomlError's Display embeds the whole file. let mut world = crate::world::load(path).unwrap_or_else(|e| { let first = e.to_string().lines().next().unwrap_or_default().to_string(); - panic!("load start.toml failed (see todo.md #5, maps are still pre-BoardSpec): {first}") + panic!("load start.toml failed (see todo.md #1, maps are still pre-BoardSpec): {first}") }); // Pin to the "start" board regardless of the world's current default entry point. world.start = "start".to_string(); @@ -523,7 +524,7 @@ fn a_send_cycle_defers_instead_of_recursing() { // ── enter hook ────────────────────────────────────────────────────────────── // `enter(me, dir)` fires on a **sensor** when the player steps onto its cell, with // `dir` the side the player came from. This is the only remaining enter trigger: -// object movement, pushes, teleports and shifts dispatch no hooks (see todo.md). +// object movement, pushes, teleports and shifts dispatch no hooks at all. #[test] fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() { diff --git a/kiln-core/src/tile.rs b/kiln-core/src/tile.rs index 117b253..5e2744f 100644 --- a/kiln-core/src/tile.rs +++ b/kiln-core/src/tile.rs @@ -135,11 +135,17 @@ impl ScriptKey { } } - pub fn source<'a>(&self, sources: &'a HashMap) -> Option<&'a str> { + /// Attempt to find and return the source for the given script key: + /// - For `None`, return Ok(None) since there's not a script to find + /// - For `World`, return either Ok(Some(&str)) or Err if it's not in the given hashmap + /// - For `Builtin`, return Ok(Some(&str)) assuming the builtin is a valid name (Err in the unlikely case...) + pub fn source<'a>(&self, sources: &'a HashMap) -> Result, String> { match self { - ScriptKey::None => None, - ScriptKey::World(name) => sources.get(name).map(String::as_str), - key @ ScriptKey::Builtin(_) => BUILTIN_SOURCES.get(key).copied(), + ScriptKey::None => Ok(None), + ScriptKey::World(name) => sources.get(name).map(String::as_str) + .map_or(Err(format!("unknown script '{name}'")), |s| Ok(Some(s))), + key @ ScriptKey::Builtin(name) => BUILTIN_SOURCES.get(key) + .map_or(Err(format!("No builtin script '{name}'")), |s| Ok(Some(*s))), } } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 8185d79..643c9aa 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -181,29 +181,46 @@ mod tests { use super::{BoardWidget, DARKNESS_BG}; use crate::utils::rgba8_to_color; use kiln_core::Board; + use kiln_core::board_spec::BoardSpec; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::widgets::Widget; - use kiln_core::board_spec::BoardSpec; + use std::collections::HashSet; + + /// Deserializes one `[boards.NAME]` table into a [`Board`]. + /// + /// The script pool passed to `into_board` is empty, so boards built here must be + /// script-free — it validates every object's `script` against that pool. + fn board_from(toml: &str) -> Board { + let spec: BoardSpec = toml::from_str(toml).expect("board spec parses"); + spec.into_board(&HashSet::new()).expect("board spec converts") + } + + /// An `{ r, g, b, a }` color table. `Glyph` derives serde straight onto + /// `color::Rgba8`, so map TOML spells colors out structurally rather than as + /// `"#rrggbb"` strings. + fn rgb(r: u8, g: u8, b: u8) -> String { + format!("{{ r = {r}, g = {g}, b = {b}, a = 255 }}") + } /// Builds a tiny dark board: a 5×1 corridor with the player at the left end /// and a wall at x=2 occluding the two cells behind it. fn dark_corridor() -> Board { - let toml = r##" - [map] + board_from( + r##" name = "corridor" width = 5 height = 1 dark = true - [grid] - content = "@ # " - [grid.palette] - "@" = { kind = "player" } - "#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" } - "##; - let mf: BoardSpec = toml::from_str(toml).unwrap(); - Board::try_from(mf).unwrap() + grid = "@ # " + [palette."@"] + type = "player" + [palette."#"] + type = "builtin" + kind = "wall" + "##, + ) } #[test] @@ -243,21 +260,36 @@ mod tests { #[test] fn colored_object_light_tints_nearby_and_darkens_far() { - // A dark 6×1 corridor, no player torch: a single red light object at x=2 + // A dark 6×1 corridor, no player torch: a single red light source at x=2 // (radius 2) tints its own cell red; a cell beyond its reach is darkness. - let toml = r##" - [map] + // + // The light is a Sensor, not a grid object: every grid object is solid now, + // and a lamp you can walk through belongs off-grid. The player sits at x=0 + // (Board::lighting reads player_pos for the sightline) but carries no torch, + // so the sensor is the only light. + let board = board_from(&format!( + r##" name = "lit" width = 6 height = 1 dark = true - [grid] - sparse = [ { x = 2, y = 0, ch = "L" } ] - [grid.palette] - "L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 } - "##; - let board = Board::try_from(toml::from_str::(toml).unwrap()).unwrap(); - let fov = board.lighting(0); // no player torch — only the object lights + grid = "@ " + + [palette."@"] + type = "player" + + [[sensors]] + x = 2 + y = 0 + draw_layer = "Above" + opaque = false + glow = 2 + glyph = {{ tile = 1, fg = {fg}, bg = {bg} }} + "##, + fg = rgb(255, 0, 0), + bg = rgb(0, 0, 0), + )); + let fov = board.lighting(0); // no player torch — only the sensor lights let area = Rect::new(0, 0, 6, 1); let mut buf = Buffer::empty(area);