gave up and limited recursion depth

This commit is contained in:
2026-08-15 14:00:23 -05:00
parent 56f0a1b385
commit 1fa47f6418
6 changed files with 110 additions and 14 deletions
+16 -10
View File
@@ -112,16 +112,16 @@ impl Board {
/// ///
/// With a single grid the draw order is a fixed precedence (no layer walk): /// 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); /// 1. a sensor `Above` the grid whose glyph is visible (drawn on top of the
/// 2. an object at the cell — a solid object always, otherwise the first /// player too);
/// non-transparent non-solid object (`tile != 0`, so invisible objects exist); /// 2. the grid cell's tile — the player or an object — when its glyph is
/// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when /// visible (`tile != 0`, so invisible objects exist);
/// visible (`tile != 0`); /// 3. a sensor `Below` the grid whose glyph is visible (only reachable
/// 4. a portal at the cell (portals sit on a transparent grid cell); /// because the grid cell drew nothing);
/// 5. a [`decoration`](Board::decorations) at the cell (reached only because the /// 4. a portal at the cell (portals sit on a transparent grid cell), when
/// grid cell was empty); /// its glyph is visible;
/// 6. the [`floor`](Board::floor) glyph, if any; /// 5. the [`floor`](Board::floor) glyph, if any;
/// 7. the canonical black `Empty` glyph. /// 6. the canonical black `Empty` glyph.
/// ///
/// Panics if out of bounds. /// Panics if out of bounds.
pub fn glyph_at<P: Into<Point>>(&self, p: P) -> Glyph { pub fn glyph_at<P: Into<Point>>(&self, p: P) -> Glyph {
@@ -145,6 +145,12 @@ impl Board {
return below.scripting.glyph; 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. // Otherwise the floor, or the canonical black empty cell.
self.floor.glyph_at(p, self.width).unwrap_or_else(Glyph::transparent) self.floor.glyph_at(p, self.width).unwrap_or_else(Glyph::transparent)
} }
+56
View File
@@ -235,6 +235,7 @@ impl BoardSpec {
mod tests { mod tests {
use super::BoardSpec; use super::BoardSpec;
use crate::board::Board; use crate::board::Board;
use crate::glyph::Glyph;
use crate::tile::Tile; use crate::tile::Tile;
use std::collections::HashSet; use std::collections::HashSet;
@@ -414,4 +415,59 @@ target_name = "other"
// The same board converts once the world supplies that script. // The same board converts once the world supplies that script.
assert!(build(&toml_src, &["ghost"]).is_ok()); 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());
}
} }
+19
View File
@@ -74,6 +74,11 @@ pub struct GameState {
/// position in [`Board::player`](crate::board::Board::player). Scripts mutate /// position in [`Board::player`](crate::board::Board::player). Scripts mutate
/// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it. /// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it.
pub player: PlayerRef, 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 { impl GameState {
@@ -102,6 +107,7 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
resolve_move_recursion_level: 0,
player player
}; };
state.drain_log(); state.drain_log();
@@ -202,6 +208,7 @@ impl GameState {
// actions immediately so the next object sees the updated board. // actions immediately so the next object sees the updated board.
let ids = self.board().all_ids(); let ids = self.board().all_ids();
for id in 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); let actions = self.scripts.run_tick_on(id, secs);
self.apply_actions(actions); self.apply_actions(actions);
} }
@@ -385,7 +392,18 @@ impl GameState {
self.run_init(); self.run_init();
} }
fn reset_resolve_move_limit(&mut self) {
self.resolve_move_recursion_level = 0;
}
fn resolve_move<P: Into<Point>>(&mut self, from: P, dir: Direction) -> bool { fn resolve_move<P: Into<Point>>(&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(); let from = from.into();
// Get the target coords, if they're out of bounds then the move fails. // Get the target coords, if they're out of bounds then the move fails.
let target: Point = dir.from_point(from); let target: Point = dir.from_point(from);
@@ -441,6 +459,7 @@ impl GameState {
return; return;
} }
self.reset_resolve_move_limit(); // Starting a new chain of moves, reset the limit
self.resolve_move(player_loc, dir); self.resolve_move(player_loc, dir);
// Check if we actually moved // Check if we actually moved
+7
View File
@@ -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. /// Whether this glyph draws anything at all.
/// ///
/// `false` for the [`transparent`](Glyph::transparent) sentinel, which is how /// `false` for the [`transparent`](Glyph::transparent) sentinel, which is how
+8 -2
View File
@@ -26,6 +26,12 @@ pub struct Portal {
pub target_board: String, pub target_board: String,
/// Name of the arrival portal on the target board. /// Name of the arrival portal on the target board.
pub target_name: String, pub target_name: String,
#[serde(skip_serializing_if = "Option::is_none")] /// Visual representation of this portal. Omitted from a map file, it
pub glyph: Option<Glyph> /// 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
} }
+4 -2
View File
@@ -7,6 +7,7 @@
use crate::board::Board; use crate::board::Board;
use crate::board::tests::open_board; use crate::board::tests::open_board;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph;
use crate::portal::Portal; use crate::portal::Portal;
use crate::utils::{Direction, Point}; use crate::utils::{Direction, Point};
use crate::world::World; use crate::world::World;
@@ -21,7 +22,8 @@ fn make_board(player: (usize, usize), portals: Vec<Portal>) -> Board {
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( fn portal(
x: usize, x: usize,
y: usize, y: usize,
@@ -34,7 +36,7 @@ fn portal(
name: name.to_string(), name: name.to_string(),
target_board: target_board.to_string(), target_board: target_board.to_string(),
target_name: target_name.to_string(), target_name: target_name.to_string(),
glyph: None, glyph: Glyph::portal(),
} }
} }