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):
///
/// 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<P: Into<Point>>(&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)
}
+56
View File
@@ -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());
}
}
+19
View File
@@ -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<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();
// 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
+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.
///
/// `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,
/// Name of the arrival portal on the target board.
pub target_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub glyph: Option<Glyph>
/// 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
}
+4 -2
View File
@@ -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<Portal>) -> 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(),
}
}