more test cleanup
This commit is contained in:
@@ -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<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> {
|
||||
if !board.in_bounds((x, y)) {
|
||||
Err(format!("teleport({target},{x},{y}): out of bounds"))
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
|
||||
/// edge or a non-pushable solid.
|
||||
|
||||
@@ -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<Vec<Option<TileSpec>>, String> {
|
||||
pub fn build_grid(&self) -> Result<Vec<Option<TileSpec>>, 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<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![];
|
||||
|
||||
// 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<Board, String> {
|
||||
pub fn into_board(self, script_names: &HashSet<&String>) -> Result<Board, String> {
|
||||
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(),
|
||||
};
|
||||
|
||||
+8
-41
@@ -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<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.
|
||||
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<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`
|
||||
/// reactions for the caller to resolve after the board borrow drops.
|
||||
///
|
||||
|
||||
+31
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,3 @@ fn blocked_reports_solid_and_clear() {
|
||||
game.run_init();
|
||||
assert_eq!(glyph(&game, id).tile, 7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+10
-4
@@ -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 {
|
||||
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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-21
@@ -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::<BoardSpec>(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);
|
||||
|
||||
Reference in New Issue
Block a user