drain object queues after each object

This commit is contained in:
2026-07-10 01:20:26 -05:00
parent 386967e936
commit 325d2c27dd
13 changed files with 259 additions and 106 deletions
+114 -25
View File
@@ -1,12 +1,45 @@
use crate::action::{Action, SendArg};
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)>,
/// `(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.sends.extend(other.sends);
}
/// Whether there is anything left to fire.
fn is_empty(&self) -> bool {
self.bumps.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;
@@ -164,8 +197,18 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.resolve();
// 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_errors();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -178,14 +221,24 @@ impl GameState {
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
let secs = dt.as_secs_f64();
self.scripts.run_tick(secs);
// Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled.
// 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
});
self.resolve();
// 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_errors();
}
/// Drains errors collected by the script host into the game log.
@@ -196,15 +249,16 @@ impl GameState {
self.log.extend(errors);
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` and `send` hooks the moves triggered.
/// 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 fires the
/// hooks after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
/// `(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 {
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
@@ -384,13 +438,39 @@ impl GameState {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
}
}
for (bumped, dir) in bumps {
self.scripts.run_bump(bumped, dir);
// Return the reactions for the caller's settle pass rather than firing them here.
Events { bumps, 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, 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;
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
}
self.drain_errors();
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
@@ -404,7 +484,12 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice
{
self.scripts.run_send(scroll.source, &choice, SendArg::None);
// 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_errors();
}
}
@@ -513,17 +598,21 @@ impl GameState {
self.enter_board(&target_map, &target_entry);
return;
}
// Fire the grab hook and resolve it immediately so the grabbed thing's
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 {
self.scripts.run_grab(id);
self.resolve();
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.
self.scripts.run_bump(idx, dir.opposite());
self.drain_errors();
ev.bumps.push((idx, 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_errors();
}
}