From 1fa47f6418eeeb8df2a24c45ab9a11c7d32eab91 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 15 Aug 2026 14:00:23 -0500 Subject: [PATCH] gave up and limited recursion depth --- kiln-core/src/board.rs | 26 ++++++++------ kiln-core/src/board_spec.rs | 56 +++++++++++++++++++++++++++++ kiln-core/src/game.rs | 19 ++++++++++ kiln-core/src/glyph.rs | 7 ++++ kiln-core/src/portal.rs | 10 ++++-- kiln-core/src/tests/game_portals.rs | 6 ++-- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index d34c631..9811577 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -112,16 +112,16 @@ impl Board { /// /// With a single grid the draw order is a fixed precedence (no layer walk): /// - /// 1. the player (drawn on top; not part of the grid yet); - /// 2. an object at the cell — a solid object always, otherwise the first - /// non-transparent non-solid object (`tile != 0`, so invisible objects exist); - /// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when - /// visible (`tile != 0`); - /// 4. a portal at the cell (portals sit on a transparent grid cell); - /// 5. a [`decoration`](Board::decorations) at the cell (reached only because the - /// grid cell was empty); - /// 6. the [`floor`](Board::floor) glyph, if any; - /// 7. the canonical black `Empty` glyph. + /// 1. a sensor `Above` the grid whose glyph is visible (drawn on top of the + /// player too); + /// 2. the grid cell's tile — the player or an object — when its glyph is + /// visible (`tile != 0`, so invisible objects exist); + /// 3. a sensor `Below` the grid whose glyph is visible (only reachable + /// because the grid cell drew nothing); + /// 4. a portal at the cell (portals sit on a transparent grid cell), when + /// its glyph is visible; + /// 5. the [`floor`](Board::floor) glyph, if any; + /// 6. the canonical black `Empty` glyph. /// /// Panics if out of bounds. pub fn glyph_at>(&self, p: P) -> Glyph { @@ -145,6 +145,12 @@ impl Board { return below.scripting.glyph; } + // A portal at the cell, if its glyph is visible (a `tile = 0` portal is + // deliberately invisible, so the floor shows through instead). + if let Some(portal) = self.portal_at(p) && portal.glyph.is_visible() { + return portal.glyph; + } + // Otherwise the floor, or the canonical black empty cell. self.floor.glyph_at(p, self.width).unwrap_or_else(Glyph::transparent) } diff --git a/kiln-core/src/board_spec.rs b/kiln-core/src/board_spec.rs index cea0b58..c0ab1a4 100644 --- a/kiln-core/src/board_spec.rs +++ b/kiln-core/src/board_spec.rs @@ -235,6 +235,7 @@ impl BoardSpec { mod tests { use super::BoardSpec; use crate::board::Board; + use crate::glyph::Glyph; use crate::tile::Tile; use std::collections::HashSet; @@ -414,4 +415,59 @@ target_name = "other" // The same board converts once the world supplies that script. assert!(build(&toml_src, &["ghost"]).is_ok()); } + + /// A `[[portals]]` block with a default glyph, anchored at `(x, y)`. + fn portal_spec(x: usize, y: usize) -> String { + format!( + r#" +[[portals]] +x = {x} +y = {y} +name = "east_door" +target_board = "room2" +target_name = "west_door" +"# + ) + } + + #[test] + fn a_portal_without_a_glyph_defaults_to_the_portal_glyph() { + let board = build(&spec_toml(3, 1, " @ ", &[PLAYER], &portal_spec(2, 0)), &[]) + .expect("a portal with no glyph converts"); + assert_eq!(board.portals[0].glyph, Glyph::portal()); + } + + #[test] + fn an_omitted_portal_glyph_round_trips_as_an_omitted_key() { + // The default portal glyph is not serialized — omission is how it round-trips. + let spec: BoardSpec = + toml::from_str(&spec_toml(3, 1, " @ ", &[PLAYER], &portal_spec(2, 0))) + .expect("board table parses"); + let text = toml::to_string(&spec).unwrap(); + assert!(!text.contains("glyph"), "default portal glyph is omitted: {text}"); + } + + #[test] + fn glyph_at_draws_a_visible_portal_but_not_a_transparent_one() { + // A portal with the default (visible) glyph renders at its cell… + let board = build(&spec_toml(3, 1, " @ ", &[PLAYER], &portal_spec(2, 0)), &[]) + .expect("portals convert"); + assert_eq!(board.glyph_at((2, 0)), Glyph::portal()); + + // …while an explicitly-transparent one (`tile = 0`) stays invisible, so the + // floor (here, blank) shows through instead — the sensor-like opt-out. + let invisible = r##" +[[portals]] +x = 2 +y = 0 +name = "east_door" +target_board = "room2" +target_name = "west_door" +glyph = { tile = 0, fg = "#000000", bg = "#000000" } +"##; + let board = build(&spec_toml(3, 1, " @ ", &[PLAYER], invisible), &[]) + .expect("portals convert"); + assert_eq!(board.portals[0].glyph, Glyph::transparent()); + assert!(!board.glyph_at((2, 0)).is_visible()); + } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 08dc188..cec40c4 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -74,6 +74,11 @@ pub struct GameState { /// position in [`Board::player`](crate::board::Board::player). Scripts mutate /// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it. pub player: PlayerRef, + /// How many times we've called resolve_move since the last time this counter + /// was reset. Because it's impossible to determine whether a given move + its + /// triggered effects will ever terminate, we just cut it at a max limit of + /// recursions + pub resolve_move_recursion_level: u32 } impl GameState { @@ -102,6 +107,7 @@ impl GameState { speech_bubbles: Vec::new(), active_scroll: None, board_transition: None, + resolve_move_recursion_level: 0, player }; state.drain_log(); @@ -202,6 +208,7 @@ impl GameState { // actions immediately so the next object sees the updated board. let ids = self.board().all_ids(); for id in ids { + self.reset_resolve_move_limit(); // Each object gets an independent limit of resolve_move recursions let actions = self.scripts.run_tick_on(id, secs); self.apply_actions(actions); } @@ -385,7 +392,18 @@ impl GameState { self.run_init(); } + fn reset_resolve_move_limit(&mut self) { + self.resolve_move_recursion_level = 0; + } + fn resolve_move>(&mut self, from: P, dir: Direction) -> bool { + // Check the recursion level because we may already be over it: + if self.resolve_move_recursion_level > 100 { + self.log.push(LogLine::error("resolve_move recursion limit reached")); + return false; + } + self.resolve_move_recursion_level += 1; + let from = from.into(); // Get the target coords, if they're out of bounds then the move fails. let target: Point = dir.from_point(from); @@ -441,6 +459,7 @@ impl GameState { return; } + self.reset_resolve_move_limit(); // Starting a new chain of moves, reset the limit self.resolve_move(player_loc, dir); // Check if we actually moved diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs index 7a75a88..f9aaf9e 100644 --- a/kiln-core/src/glyph.rs +++ b/kiln-core/src/glyph.rs @@ -107,6 +107,13 @@ impl Glyph { } } + /// Whether this glyph is the canonical [`portal`](Glyph::portal) glyph; + /// keeps it out of saved map files, since omitting the field is how the + /// default round-trips. + pub fn is_portal_default(&self) -> bool { + self == &Self::portal() + } + /// Whether this glyph draws anything at all. /// /// `false` for the [`transparent`](Glyph::transparent) sentinel, which is how diff --git a/kiln-core/src/portal.rs b/kiln-core/src/portal.rs index cd41450..d5d9de1 100644 --- a/kiln-core/src/portal.rs +++ b/kiln-core/src/portal.rs @@ -26,6 +26,12 @@ pub struct Portal { pub target_board: String, /// Name of the arrival portal on the target board. pub target_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub glyph: Option + /// Visual representation of this portal. Omitted from a map file, it + /// defaults to [`Glyph::portal`] — `≡`, black on white — so portals are + /// visible unless the author opts out with `tile = 0` (the transparent + /// sentinel). Unlike sensors (which are glyphless by default), a portal + /// sits on a transparent grid cell and this glyph is what + /// [`Board::glyph_at`](crate::board::Board::glyph_at) draws for it. + #[serde(default = "Glyph::portal", skip_serializing_if = "Glyph::is_portal_default")] + pub glyph: Glyph } diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 64e29c6..2ca6867 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -7,6 +7,7 @@ use crate::board::Board; use crate::board::tests::open_board; use crate::game::GameState; +use crate::glyph::Glyph; use crate::portal::Portal; use crate::utils::{Direction, Point}; use crate::world::World; @@ -21,7 +22,8 @@ fn make_board(player: (usize, usize), portals: Vec) -> Board { board } -/// A glyphless portal at `(x, y)` pointing at `target_name` on `target_board`. +/// A portal at `(x, y)` pointing at `target_name` on `target_board`, using the +/// default portal glyph. fn portal( x: usize, y: usize, @@ -34,7 +36,7 @@ fn portal( name: name.to_string(), target_board: target_board.to_string(), target_name: target_name.to_string(), - glyph: None, + glyph: Glyph::portal(), } }