Files
kiln/kiln-core/src/game.rs
T
2026-07-11 14:47:08 -05:00

1048 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::action::{Action, BoardAction, SendArg};
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::collections::HashSet;
use std::time::Duration;
/// The bump and send reactions produced while applying a batch of actions.
///
/// [`GameState::apply_actions`] collects these but does not fire them; the
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a
/// bumped object reacts to the fully-updated board.
#[derive(Default)]
struct Events {
/// `(bumped object, direction the bump came from)` for each triggered `bump`.
bumps: Vec<(ObjectId, Direction)>,
/// `(entered non-solid object, direction the entrant came from)` for each
/// `enter` — a solid relocating onto a non-solid object's cell.
enters: Vec<(ObjectId, Direction)>,
/// `(target object, function name, argument)` for each `send`.
sends: Vec<(ObjectId, String, SendArg)>,
}
impl Events {
/// Appends `other`'s reactions onto `self`.
fn merge(&mut self, other: Events) {
self.bumps.extend(other.bumps);
self.enters.extend(other.enters);
self.sends.extend(other.sends);
}
/// Whether there is anything left to fire.
fn is_empty(&self) -> bool {
self.bumps.is_empty() && self.enters.is_empty() && self.sends.is_empty()
}
}
/// Records which `(object, hook/fn, args)` reactions have already fired during a
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire
/// a key twice, so a bump/send cascade always terminates — even if two objects
/// bump each other in a cycle, each side fires at most once. Reset per invocation.
type CalledSet = HashSet<(ObjectId, String, String)>;
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef};
/// An active scroll overlay opened by a scripted object via `scroll()`.
///
/// While a `Scroll` is present on [`GameState`], the front-end should display
/// the overlay and pause ticks. When the player selects a choice (or dismisses),
/// the front-end sets [`choice`](Scroll::choice); [`GameState::tick`] calls
/// [`GameState::handle_scroll`] at the start of each tick to dispatch and clear it.
pub struct Scroll {
/// The object whose `scroll()` call opened this overlay.
pub source: ObjectId,
/// The lines of content to display.
pub lines: Vec<ScrollLine>,
/// The choice key the player selected, or `None` if dismissed without a choice.
/// Set by the front-end; consumed by [`GameState::handle_scroll`].
pub choice: Option<String>,
}
/// An active speech bubble emitted by a scripted object via `say()`.
pub struct SpeechBubble {
/// The object whose `say()` call created this bubble.
pub object_id: ObjectId,
/// The text to display.
pub text: String,
/// Seconds remaining before the bubble disappears.
pub remaining: f64,
}
/// Holds the active game world and provides game-logic operations.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
/// board is currently active. Front-ends reach the active board through
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
/// read the board and queue mutations via commands applied by `resolve` after
/// each script batch.
pub struct GameState {
/// All boards and world-level scripts.
world: World,
/// Key of the currently active board in [`world.boards`](World::boards).
current_board_name: String,
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
/// The Rhai scripting runtime for the active board's objects.
scripts: ScriptHost,
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
pub speech_bubbles: Vec<SpeechBubble>,
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
pub active_scroll: Option<Scroll>,
/// Remaining seconds for the board-entry transition animation, set to `1.0`
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>,
/// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// per-board: it persists across board transitions, unlike the per-board
/// 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,
}
impl GameState {
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
///
/// The starting board's objects are compiled and registered with the
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
/// call [`run_init`](GameState::run_init) once the game is ready to start.
/// Compile errors are surfaced into the log immediately.
pub fn from_world(world: World) -> Self {
let name = world.start.clone();
let player = Player::new_ref();
let host = ScriptHost::new(
world
.boards
.get(&name)
.expect("world::load guarantees start board exists").clone(),
player.clone(),
&world.scripts,
);
let mut state = Self {
world,
current_board_name: name,
log: Vec::new(),
scripts: host,
speech_bubbles: Vec::new(),
active_scroll: None,
board_transition: None,
player
};
state.drain_log();
state
}
/// Creates a `GameState` from a single [`Board`] with no scripts.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn new(board: Board) -> Self {
Self::with_scripts(board, std::collections::HashMap::new())
}
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
let world = World {
name: String::new(),
start: "board".to_string(),
torch: crate::fov::SIGHT_RADIUS as u32,
scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
};
Self::from_world(world)
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.current_board_name].borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
self.world.boards[&self.current_board_name].borrow_mut()
}
/// The name (key) of the currently active board within the world.
pub fn current_board_name(&self) -> &str {
&self.current_board_name
}
/// The player's torch radius (world-wide config; see [`World::torch`]),
/// passed to [`Board::lighting`] when rendering a dark board.
pub fn torch(&self) -> u32 {
self.world.torch
}
/// Returns a clone of the `Rc` for the active board.
///
/// Lets a front-end hold a reference to the current board across a board
/// transition — the old board stays alive in `world.boards`, so the clone
/// keeps it reachable even after `current_board_name` changes.
pub fn board_rc(&self) -> std::rc::Rc<std::cell::RefCell<Board>> {
self.world.boards[&self.current_board_name].clone()
}
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
/// Runs the `init()` hook of every scripted object, pumps their queues, and
/// resolves the resulting actions. Call once, after the whole map is loaded and
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
// Run each object's init hook in ascending id order, applying its actions
// immediately so a later object's init sees what an earlier one did.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_init_on(id);
ev.merge(self.apply_actions(actions));
}
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
/// Called once per frame by the front-end's game loop. First processes any active
/// scroll (dispatching the player's choice if set), then drives object tick hooks
/// and resolves queued actions.
pub fn tick(&mut self, dt: Duration) {
// Process any pending scroll choice before running scripts; ticks are
// suppressed by the front-end while a scroll overlay is visible, so
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
let secs = dt.as_secs_f64();
// Expire speech bubbles once per frame, before applying new actions so a
// fresh say() this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs;
b.remaining > 0.0
});
// Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_tick_on(id, secs);
ev.merge(self.apply_actions(actions));
}
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
/// Drains the log lines collected by the script host — script `log()` output
/// plus engine/runtime errors — into the game log.
fn drain_log(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution /
// set an error state when a script faults.
let lines = self.scripts.take_logs();
self.log.extend(lines);
}
/// Applies one object's drained `actions` to the board and returns the `bump`
/// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)).
///
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the
/// player-stat / bubble / scroll changes after the borrow drops. The collected
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// `enter` reactions: a solid relocating onto a non-solid object's cell.
let mut enters: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
let mut gem_delta: i64 = 0;
let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
match ba.action {
Action::Move(dir) => {
let StepOutcome { bumped, entered } =
step_object(&mut board, ba.source, dir);
// The bump / enter "comes from" the side the mover advanced
// from, i.e. the opposite of its travel direction.
if let Some(bumped) = bumped {
bumps.push((bumped, dir.opposite()));
}
for id in entered {
enters.push((id, dir.opposite()));
}
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.glyph.tile = tile;
}
}
Action::SetLight(radius) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.light = radius;
}
}
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = board.objects.get_mut(&target) {
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
}
}
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
Action::Say(text, duration) => new_bubbles.push(SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
// Collected and fired after the board borrow drops, like bumps.
Action::Send {
target,
fn_name,
arg,
} => {
sends.push((target, fn_name, arg));
}
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
new_scroll = Some(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
log_sink.error(format!(
"teleport({target},{x},{y}): out of bounds"
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize);
let blocked = matches!(
board.solid_at(ux, uy),
Some(s) if !s.player()
);
if blocked {
log_sink.error(format!(
"teleport(player,{x},{y}): destination is solid"
));
} else {
let (old_x, old_y) = (board.player.x, board.player.y);
board.player.x = ux as i64;
board.player.y = uy as i64;
// The player is solid, so it may land on non-solids:
// fire `enter` with a best-effort came-from direction
// (the jump is arbitrary, so it has no exact cardinal).
if let Some(from) =
Direction::from_delta(old_x - ux as i64, old_y - uy as i64)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
}
} else if let Ok(tid) = ObjectId::try_from(target) {
// Move object `tid` to (x, y). A solid destination blocks
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
)),
Some(obj) => {
let source_solid = obj.solid;
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
log_sink.error(format!(
"teleport({target},{x},{y}): destination is solid"
));
} else {
if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
}
// Only a solid mover triggers `enter`; the
// direction is best-effort (arbitrary jump).
if source_solid
&& let Some(from) = Direction::from_delta(
old_x - ux as i64,
old_y - uy as i64,
)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
}
}
}
} else {
log_sink.error(format!(
"teleport({target},{x},{y}): invalid target id"
));
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if !board.in_bounds((x, y)) {
log_sink.error(format!("push({x},{y}): out of bounds"));
} else {
// Each pushed solid stepped one cell in `dir`; fire `enter`
// on any non-solid it landed on (came-from `dir.opposite()`).
for (cx, cy) in board.push(x as usize, y as usize, dir) {
for id in board.non_solid_object_ids_at(cx, cy) {
enters.push((id, dir.opposite()));
}
}
}
}
// apply_shift moves the named cells, returning error lines plus the
// relocations it performed (for `enter` at each destination).
Action::Shift(cells) => {
let outcome = board.apply_shift(&cells);
for line in outcome.errors {
log_sink.line(line);
}
for (from, to) in outcome.moves {
// A shift can rotate non-adjacent cells, so the came-from
// direction is best-effort (dominant axis of the jump).
if let Some(from_dir) =
Direction::from_delta(from.0 - to.0, from.1 - to.1)
{
for id in
board.non_solid_object_ids_at(to.0 as usize, to.1 as usize)
{
enters.push((id, from_dir));
}
}
}
}
// Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
// Accumulated and applied to `self.player.health` after the borrow drops.
Action::AlterHealth(dh) => health_delta += dh,
// Collected and applied to `self.player.keys` after the borrow drops.
Action::SetKey(color, present) => key_changes.push((color, present)),
// A grab thing despawns itself from its grab() hook.
Action::Die => {
board.remove_object(ba.source);
}
}
}
}
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles.push(bubble);
}
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
// Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 {
self.player.borrow_mut().alter_gems(gem_delta);
}
// Apply the net health change (clamped to [0, max_health]).
if health_delta != 0 {
self.player.borrow_mut().alter_health(health_delta);
}
for (color, present) in key_changes {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
Events {
bumps,
enters,
sends,
}
}
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
/// until the board is quiescent, applying each hook's actions as it runs.
///
/// `called` records every `(object, hook/fn, args)` already fired this
/// invocation; a reaction whose key is already present is skipped. Since each
/// key fires at most once, the loop is finite even when objects bump each
/// other in a cycle — the guard is what makes bump-loops impossible.
fn settle(&mut self, initial: Events, called: &mut CalledSet) {
let mut pending = initial;
while !pending.is_empty() {
let mut next = Events::default();
for (id, dir) in std::mem::take(&mut pending.bumps) {
// Skip a bump already fired this pass (dedup key includes the direction).
if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_bump(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, dir) in std::mem::take(&mut pending.enters) {
// Skip an enter already fired this pass (dedup key includes the direction).
if !called.insert((id, "enter".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_enter(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, fn_name, arg) in std::mem::take(&mut pending.sends) {
// Dedup key: target + function name + argument.
if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) {
continue;
}
let actions = self.scripts.run_send(id, &fn_name, arg);
next.merge(self.apply_actions(actions));
}
pending = next;
}
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
/// to the source object via `send`.
///
/// Called automatically by [`tick`](GameState::tick) as its first step. The
/// front-end sets [`Scroll::choice`] before resuming ticks; if no choice was
/// made (player dismissed), `choice` stays `None` and the scroll is cleared
/// without dispatching.
pub fn handle_scroll(&mut self) {
if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice
{
// 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);
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
}
/// Switches the active board, placing the player at the named arrival portal,
/// rebuilding the script host, and running `init()` hooks on the new board's objects.
///
/// Called automatically by [`try_move`](GameState::try_move) when the player
/// steps onto a portal. Front-ends may check [`board_transition`](GameState::board_transition)
/// to play a visual effect during the switch.
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
if !self.world.boards.contains_key(target_map) {
self.log.push(LogLine::error(format!(
"portal target board {target_map:?} not found"
)));
return;
}
// Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map]
.borrow()
.portals
.iter()
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
let (ax, ay) = match arrival {
Some(pos) => pos,
None => {
self.log.push(LogLine::error(format!(
"portal entry {target_entry:?} not found on board {target_map:?}"
)));
return;
}
};
// 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.
self.current_board_name = target_map.to_string();
self.board_mut().player = PlayerPos {
x: ax as i64,
y: ay as i64,
};
self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
self.world.boards[&self.current_board_name].clone(),
self.player.clone(),
&self.world.scripts,
);
// Stub hook for the front-end transition animation (1 second).
self.board_transition = Some(1.0);
// Run init hooks and resolve their queued actions.
self.run_init();
}
/// Attempts to move the player one cell in `dir`.
///
/// The move is ignored if the target cell is out of bounds, or it is neither
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If a solid object lies in the path — directly
/// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) {
let bumped;
let grabbed;
let portal_target;
// Non-solid objects the player (or the crates it shoved) landed on this move,
// collected while the board is borrowed and fired as `enter` after the borrow.
let mut entered: Vec<ObjectId> = Vec::new();
{
let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player — possibly through a
// chain of crates the player is shoving (see `bump_target`) — but a grab
// thing fires grab() instead of bump(), so don't also bump it.
bumped = if grabbed.is_none() {
board.bump_target(nx, ny, dir)
} else {
None
};
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Don't push a grab thing aside — walk onto it. Otherwise shove any
// pushable chain out of the way (no-op when there's nothing to push).
if grabbed.is_none() {
// Each pushed solid lands on a cell that may hold a non-solid.
for (cx, cy) in board.push(nx, ny, dir) {
entered.extend(board.non_solid_object_ids_at(cx, cy));
}
}
board.player.x = nx as i64;
board.player.y = ny as i64;
// The player is solid, so any non-solid on its new cell gets `enter`.
entered.extend(board.non_solid_object_ids_at(nx, ny));
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board
.portals
.iter()
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
portal_target = None;
}
}
// A portal takes priority: board transitions skip the bump/enter hooks.
if let Some((target_map, target_entry)) = portal_target {
self.enter_board(&target_map, &target_entry);
return;
}
let mut ev = Events::default();
// Fire the grab hook and apply it immediately so the grabbed thing's
// die()/alter_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed {
let actions = self.scripts.run_grab(id);
ev.merge(self.apply_actions(actions));
}
if let Some(idx) = bumped {
// The player advanced in `dir`, so the bump arrives from the opposite side.
ev.bumps.push((idx, dir.opposite()));
}
for id in entered {
// The player advanced in `dir`, so it entered from the opposite side.
ev.enters.push((id, dir.opposite()));
}
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
}
/// 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.
///
/// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded for the solid object the move presses into — directly, or at the
/// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it
/// gets pushed aside or blocks the move. Walls and crates carry no script, so only
/// solid objects yield a bump. An `enter` is recorded for every non-solid object a
/// solid lands on: the mover's destination cell (only when the mover is itself solid)
/// and each cell a pushed crate moved into (crates are always solid entrants).
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
let mut out = StepOutcome {
bumped: None,
entered: Vec::new(),
};
let (dx, dy): (i64, i64) = dir.into();
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.solid)) else {
return out;
};
let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) {
return out;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its id is stable).
// Walks through a pushed crate chain to the object it presses against.
out.bumped = board.bump_target(nx, ny, dir);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Shove a crate/object out of the way (no-op otherwise); each pushed solid
// may land on a non-solid, which gets `enter` regardless of the mover.
for (cx, cy) in board.push(nx, ny, dir) {
out.entered.extend(board.non_solid_object_ids_at(cx, cy));
}
let obj = board.objects.get_mut(&id).expect("id checked above");
obj.x = nx;
obj.y = ny;
// Only a solid mover triggers `enter` on non-solids under its own new cell.
if solid {
out.entered.extend(board.non_solid_object_ids_at(nx, ny));
}
}
out
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::Direction;
use crate::archetype::{Archetype, Builtin};
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
#[test]
fn walking_onto_a_gem_grabs_it() {
// A gem terrain cell at (1,0); expanding turns it into the builtin gem
// object running scripts/gem.rhai. The player starts at (0,0).
let mut board = open_board(3, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
board.expand_builtin_archetypes();
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player.borrow().gems, 1);
assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
}
#[test]
fn pushing_a_gem_into_the_player_does_not_grab() {
// A non-solid script object at (0,0) pushes the gem at (1,0) east toward the
// player at (2,0). Grab now fires only on player movement, so the gem is an
// ordinary solid here: the chain can't move (the player is backed against
// the board edge), so nothing happens and the gem is not collected.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
board.expand_builtin_archetypes();
let scripts = HashMap::from([(
"s".to_string(),
"fn tick(dt) { if Queue.length() == 0 { push(1, 0, East); } }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(16));
game.tick(Duration::from_millis(16));
// No grab: the gem is untouched and the player never moved.
assert_eq!(game.player.borrow().gems, 0);
assert!(game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn script_shift_rotates_crates() {
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
// crate moves to (3,0), empty moves back to (2,0).
let mut game = game_with_object_script(
5,
"fn tick(m,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
fn script_passable_distinguishes_empty_solid_and_offboard() {
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
// object sits at (0,0); the player at (3,0). passable should report only the
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init(m) { \
log(if Board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if Board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if Board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let lines: Vec<String> = game
.log
.iter()
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
.collect();
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
#[test]
fn log_is_immediate_and_not_paced_by_the_queue() {
// `delay(5.0)` parks the object's action queue for 5 seconds, then `log()`
// fires. Because logging bypasses the queue entirely, the line must appear
// right after run_init (dt = 0) — under the old queued-Action behavior it
// would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(
game.log
.iter()
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
"log() should hit the game log immediately, even behind a delay"
);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
/// script spins counter-clockwise (clockwise is the default when no tag present).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
let scripts = HashMap::from([(
"spinner".to_string(),
include_str!("scripts/spinner.rhai").to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn spinner_does_not_destroy_a_blocked_neighbour() {
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
// wall, so N must not rotate onto NE — the old (can_shift-only) script
// overwrote and destroyed NE's crate. The cascade leaves everything put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
}
#[test]
fn spinner_rotates_an_arc_into_a_hole() {
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
// Clockwise, every crate advances one slot and the hole ends up at W.
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
fn spinner_ccw_rotates_the_other_way() {
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
fn spinner_animates_its_glyph_without_recoloring() {
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
// per 0.5s rotation, while the colours stay put.
let mut game = spinner_board(&[], &[]);
let id = *game.board().objects.keys().next().unwrap();
let (fg0, bg0) = {
let b = game.board();
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
};
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let b = game.board();
seq.push(b.objects[&id].glyph.tile);
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
}
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
}
#[test]
fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
"fn init(m) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let keys = game.player.borrow().keys;
assert!(keys.blue);
assert!(keys.red);
assert!(!keys.cyan); // cyan was not set by the script
// A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0);
sobj2.solid = false;
sobj2.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
"t".to_string(),
"fn init(m) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(),
)]);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.player.borrow().keys.blue);
}
#[test]
fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
r#"fn init(m) { set_key("chartreuse", true); }"#.to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.log.iter().any(|l| l.spans.iter().any(|s| s.text.contains("set_key"))));
}
}