more test cleanup

This commit is contained in:
2026-07-25 23:20:52 -05:00
parent c3be2329c0
commit ba8c72d5a5
12 changed files with 117 additions and 164 deletions
-11
View File
@@ -162,17 +162,6 @@ pub struct BoardAction {
pub(crate) action: Action, 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<Consequence> {
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> { pub fn apply_teleport(board: &mut Board, target: i64, x: i64, y: i64) -> Result<(), String> {
if !board.in_bounds((x, y)) { if !board.in_bounds((x, y)) {
Err(format!("teleport({target},{x},{y}): out of bounds")) Err(format!("teleport({target},{x},{y}): out of bounds"))
+2 -14
View File
@@ -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<BoardAction>, dt: f64) { pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) { if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) {
scr.queue.drain(self.id, target, dt) 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 { engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool {
let board = o.board.borrow(); let board = o.board.borrow();
let tx = o.x + dir.dx(); let (tx, ty) = dir.from_point(o.x, o.y);
let ty = o.y + dir.dy(); !board.in_bounds((tx, ty)) || !board.is_passable(tx as usize, ty as usize)
board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize)
}); });
} }
} }
+2 -2
View File
@@ -16,7 +16,7 @@ impl Registerable for PlayerWithPos {
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health) .register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_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("keys", |player: &mut PlayerWithPos| player.0.borrow().keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0) .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); .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player_pos().1 as i64);
} }
} }
-32
View File
@@ -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<ObjectId> {
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 /// 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 /// step in `dir` — i.e. the chain ends at a passable cell rather than the board
/// edge or a non-pushable solid. /// edge or a non-pushable solid.
+5 -5
View File
@@ -94,7 +94,7 @@ impl BoardSpec {
/// ///
/// Returns `Err` only on a grid-dimension mismatch (the single hard error); /// Returns `Err` only on a grid-dimension mismatch (the single hard error);
/// every other problem is recorded on `errors`. /// every other problem is recorded on `errors`.
pub(crate) fn build_grid(&self) -> Result<Vec<Option<TileSpec>>, String> { pub fn build_grid(&self) -> Result<Vec<Option<TileSpec>>, String> {
let grid = self.grid_chars()?; let grid = self.grid_chars()?;
// Walk the grid, filling cells and collecting placements. // 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) /// Try to check for validity of the board, return a list of errors we find (if any)
pub(crate) fn validate(&self, grid: &Vec<Option<TileSpec>>, valid_script_names: &HashSet<&String>) -> Result<(), Vec<String>> { pub fn validate(&self, grid: &Vec<Option<TileSpec>>, valid_script_names: &HashSet<&String>) -> Result<(), Vec<String>> {
let mut errors = vec![]; let mut errors = vec![];
// Check for player being positioned exactly once // Check for player being positioned exactly once
@@ -188,13 +188,13 @@ impl BoardSpec {
if errors.is_empty() { Ok(()) } else { Err(errors) } if errors.is_empty() { Ok(()) } else { Err(errors) }
} }
pub(crate) fn into_board(self, script_names: &HashSet<&String>) -> Result<Board, String> { pub fn into_board(self, script_names: &HashSet<&String>) -> Result<Board, String> {
let grid = self.build_grid()?; let grid = self.build_grid()?;
if let Err(errors) = self.validate(&grid, script_names) { if let Err(errors) = self.validate(&grid, script_names) {
return Err(errors.join("\n")); 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()); let mut tile_grid = Vec::with_capacity(grid.len());
for spec in grid.into_iter() { for spec in grid.into_iter() {
@@ -216,7 +216,7 @@ impl BoardSpec {
floor: self.floor.into_floor(self.width, self.height), floor: self.floor.into_floor(self.width, self.height),
sensors, sensors,
portals: self.portals, portals: self.portals,
next_object_id: 0, next_object_id,
dark: self.dark, dark: self.dark,
registry: Default::default(), registry: Default::default(),
}; };
+8 -41
View File
@@ -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::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::ScriptHost;
@@ -9,32 +9,6 @@ use std::collections::{BTreeSet, HashSet, VecDeque};
use std::hash::Hash; use std::hash::Hash;
use std::time::Duration; 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<H: std::hash::Hasher>(&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. /// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0; pub const SAY_DURATION: f64 = 3.0;
@@ -43,7 +17,7 @@ pub const SAY_DURATION: f64 = 3.0;
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef}; use crate::player::{Player, PlayerRef};
use crate::portal::Portal; 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()`. /// 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 // Dispatch the choice back to the source object and apply whatever it
// queues (plus any bump/send cascade), the same as a tick. // 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. // Clear per-board transient state.
self.speech_bubbles.clear(); self.speech_bubbles.clear();
self.active_scroll = None; 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(); 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().get_mut(ax, ay).replace(Tile::Player);
self.board_mut().clear_all_queues(); self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects. // 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<ObjectId>,
/// 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<ObjectId>,
}
/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter` /// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter`
/// reactions for the caller to resolve after the board borrow drops. /// reactions for the caller to resolve after the board borrow drops.
/// ///
+31 -26
View File
@@ -135,34 +135,39 @@ impl ScriptHost {
continue; continue;
} }
if let Some(source) = script_key.source(&script_sources) { match script_key.source(&script_sources) {
match engine.compile(source) { Ok(None) => { continue } // This has no source...
Ok(ast) => { Err(e) => { // Couldn't find it
let defines = |n: &str, params: usize| { failed.insert(script_key.clone());
ast.iter_functions() log_sink.error(e);
.any(|f| f.name == n && f.params.len() == params) continue;
}; }
scripts.insert( Ok(Some(source)) => {
script_key.clone(), match engine.compile(source) {
CompiledScript { Ok(ast) => {
has_init: defines("init", 1), let defines = |n: &str, params: usize| {
has_tick: defines("tick", 2), ast.iter_functions()
has_bump: defines("bump", 2), .any(|f| f.name == n && f.params.len() == params)
has_grab: defines("grab", 1), };
has_enter: defines("enter", 2), scripts.insert(
ast, script_key.clone(),
}, CompiledScript {
); has_init: defines("init", 1),
} has_tick: defines("tick", 2),
Err(err) => { // It didn't compile... has_bump: defines("bump", 2),
failed.insert(script_key.clone()); has_grab: defines("grab", 1),
log_sink.error(format!("script '{}' failed to compile: {err}", script_key.name())); has_enter: defines("enter", 2),
continue; 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;
} }
} }
-3
View File
@@ -203,6 +203,3 @@ fn blocked_reports_solid_and_clear() {
game.run_init(); game.run_init();
assert_eq!(glyph(&game, id).tile, 7); assert_eq!(glyph(&game, id).tile, 7);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
mod actions; mod actions;
// TODO(migration): map_file is not yet ported — it is blocked on the map files // 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 game_portals;
// mod map_file; // mod map_file;
mod movement; mod movement;
+5 -4
View File
@@ -92,8 +92,9 @@ fn missing_hooks_and_no_script_are_noops() {
#[test] #[test]
fn compile_and_unknown_script_errors_are_logged() { 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 // A reference to a script name that isn't in the world pool. `ScriptKey::None`
// currently a silent `continue` in ScriptHost::new, so nothing is logged. // (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 (board, scripts, _) = board_with_object(Some("ghost"), &[]);
let game = GameState::with_scripts(board, scripts); let game = GameState::with_scripts(board, scripts);
assert!( assert!(
@@ -149,7 +150,7 @@ fn start_map_greeter_runs_init() {
// Keep the failure message short: a TomlError's Display embeds the whole file. // 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 mut world = crate::world::load(path).unwrap_or_else(|e| {
let first = e.to_string().lines().next().unwrap_or_default().to_string(); 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. // Pin to the "start" board regardless of the world's current default entry point.
world.start = "start".to_string(); world.start = "start".to_string();
@@ -523,7 +524,7 @@ fn a_send_cycle_defers_instead_of_recursing() {
// ── enter hook ────────────────────────────────────────────────────────────── // ── enter hook ──────────────────────────────────────────────────────────────
// `enter(me, dir)` fires on a **sensor** when the player steps onto its cell, with // `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: // `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] #[test]
fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() { fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() {
+10 -4
View File
@@ -135,11 +135,17 @@ impl ScriptKey {
} }
} }
pub fn source<'a>(&self, sources: &'a HashMap<String, String>) -> 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<String, String>) -> Result<Option<&'a str>, String> {
match self { match self {
ScriptKey::None => None, ScriptKey::None => Ok(None),
ScriptKey::World(name) => sources.get(name).map(String::as_str), ScriptKey::World(name) => sources.get(name).map(String::as_str)
key @ ScriptKey::Builtin(_) => BUILTIN_SOURCES.get(key).copied(), .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))),
} }
} }
} }
+53 -21
View File
@@ -181,29 +181,46 @@ mod tests {
use super::{BoardWidget, DARKNESS_BG}; use super::{BoardWidget, DARKNESS_BG};
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::board_spec::BoardSpec;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::widgets::Widget; 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 /// 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. /// and a wall at x=2 occluding the two cells behind it.
fn dark_corridor() -> Board { fn dark_corridor() -> Board {
let toml = r##" board_from(
[map] r##"
name = "corridor" name = "corridor"
width = 5 width = 5
height = 1 height = 1
dark = true dark = true
[grid] grid = "@ # "
content = "@ # " [palette."@"]
[grid.palette] type = "player"
"@" = { kind = "player" } [palette."#"]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" } type = "builtin"
"##; kind = "wall"
let mf: BoardSpec = toml::from_str(toml).unwrap(); "##,
Board::try_from(mf).unwrap() )
} }
#[test] #[test]
@@ -243,21 +260,36 @@ mod tests {
#[test] #[test]
fn colored_object_light_tints_nearby_and_darkens_far() { 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. // (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" name = "lit"
width = 6 width = 6
height = 1 height = 1
dark = true dark = true
[grid] grid = "@ "
sparse = [ { x = 2, y = 0, ch = "L" } ]
[grid.palette] [palette."@"]
"L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 } type = "player"
"##;
let board = Board::try_from(toml::from_str::<BoardSpec>(toml).unwrap()).unwrap(); [[sensors]]
let fov = board.lighting(0); // no player torch — only the object lights 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 area = Rect::new(0, 0, 6, 1);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);