drain object queues after each object
This commit is contained in:
@@ -28,7 +28,7 @@ pub enum ScrollLine {
|
||||
Choice { choice: String, display: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum SendArg {
|
||||
None,
|
||||
Int(i64),
|
||||
|
||||
@@ -25,8 +25,9 @@ use rhai::{Dynamic, Engine};
|
||||
use crate::api::board::BoardRef;
|
||||
use crate::api::queue::ObjQueue;
|
||||
use crate::Direction;
|
||||
use crate::action::BoardAction;
|
||||
use crate::object_def::ObjectDef;
|
||||
use crate::script::{BoardQueue, Registerable};
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, ObjectId};
|
||||
|
||||
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
|
||||
@@ -69,7 +70,7 @@ impl ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain(&mut self, target: BoardQueue, dt: f64) {
|
||||
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
|
||||
let mut b = self.board.borrow_mut();
|
||||
if let Some(def) = b.objects.get_mut(&self.id) {
|
||||
def.queue.drain(self.id, target, dt)
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use rhai::{Dynamic, Engine};
|
||||
use crate::action::{Action, BoardAction};
|
||||
use crate::script::{BoardQueue, Registerable};
|
||||
use crate::script::Registerable;
|
||||
use crate::utils::{ErrorSink, ObjectId};
|
||||
|
||||
/// A single object's output queue.
|
||||
@@ -44,10 +44,11 @@ impl ObjQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains object `i`'s output queue onto a target queue: first advances
|
||||
/// leading `Delay` actions by dt, then drains actions into the target
|
||||
/// queue until we run out (or hit another delay)
|
||||
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
|
||||
/// Drains object `i`'s output queue into `target`: first advances leading
|
||||
/// `Delay` actions by dt, then moves the run of ready actions into `target`
|
||||
/// until we run out (or hit another delay). Each ready action is tagged with
|
||||
/// `source` so [`GameState`](crate::game::GameState) knows who issued it.
|
||||
pub fn drain(&mut self, source: ObjectId, target: &mut Vec<BoardAction>, mut dt: f64) {
|
||||
let mut queue = self.0.borrow_mut();
|
||||
loop {
|
||||
match queue.front_mut() {
|
||||
@@ -63,7 +64,7 @@ impl ObjQueue {
|
||||
}
|
||||
Some(_) => {
|
||||
let action = queue.pop_front().unwrap();
|
||||
target.borrow_mut().push(BoardAction { source, action });
|
||||
target.push(BoardAction { source, action });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-25
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-43
@@ -40,9 +40,7 @@ use rhai::{
|
||||
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
|
||||
Scope, AST,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use crate::api::board::BoardRef;
|
||||
use crate::api::object_info::ObjectInfo;
|
||||
use crate::api::player::PlayerWithPos;
|
||||
@@ -58,9 +56,6 @@ pub trait Registerable {
|
||||
fn register(engine: &mut Engine, error_sink: ErrorSink);
|
||||
}
|
||||
|
||||
/// The board queue: actions promoted from object queues, awaiting resolution.
|
||||
pub type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
|
||||
|
||||
/// A compiled script plus which lifecycle hooks it defines.
|
||||
struct CompiledScript {
|
||||
ast: AST,
|
||||
@@ -98,7 +93,6 @@ pub struct ScriptHost {
|
||||
engine: Engine,
|
||||
scripts: HashMap<String, CompiledScript>,
|
||||
scopes: HashMap<ObjectId, Scope<'static>>,
|
||||
board_queue: BoardQueue,
|
||||
errors: ErrorSink,
|
||||
board: BoardRef
|
||||
}
|
||||
@@ -112,7 +106,6 @@ impl ScriptHost {
|
||||
/// `scripts` is the world-level script pool (script name → Rhai source); it is
|
||||
/// read only during construction and not retained afterward.
|
||||
pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap<String, String>) -> Self {
|
||||
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
|
||||
let errors = ErrorSink::new();
|
||||
let mut scopes = HashMap::new();
|
||||
|
||||
@@ -196,37 +189,41 @@ impl ScriptHost {
|
||||
Self {
|
||||
engine,
|
||||
scripts,
|
||||
board_queue,
|
||||
errors,
|
||||
scopes,
|
||||
board: board_ref
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
|
||||
pub fn run_tick(&mut self, dt: f64) {
|
||||
self.run_hook_on_all(Hook::Tick, dt);
|
||||
/// Runs the `tick(me, dt)` hook on one object and returns the actions it
|
||||
/// drained (see [`run_hook_on_one`](ScriptHost::run_hook_on_one)).
|
||||
pub(crate) fn run_tick_on(&mut self, id: ObjectId, dt: f64) -> Vec<BoardAction> {
|
||||
self.run_hook_on(Hook::Tick, id, dt)
|
||||
}
|
||||
|
||||
pub fn run_init(&mut self) {
|
||||
self.run_hook_on_all(Hook::Init, 0.0)
|
||||
/// Runs the `init(me)` hook on one object and returns the actions it drained.
|
||||
pub(crate) fn run_init_on(&mut self, id: ObjectId) -> Vec<BoardAction> {
|
||||
self.run_hook_on(Hook::Init, id, 0.0)
|
||||
}
|
||||
|
||||
/// Run the given hook on every object that defines it. For hooks other than
|
||||
/// `Tick`, dt should just be 0.0
|
||||
fn run_hook_on_all(&mut self, hook: Hook, dt: f64) {
|
||||
let all_ids = self.board.borrow().all_ids();
|
||||
/// Runs `hook` on the single object `id` and returns the actions it drained.
|
||||
///
|
||||
/// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt`
|
||||
/// parameter and paces the object's delay draining); pass `0.0` otherwise.
|
||||
pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec<BoardAction> {
|
||||
let arg = match hook {
|
||||
Hook::Tick => Some(Dynamic::from(dt)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
for id in all_ids {
|
||||
self.run_hook_on_one(hook, id, arg.clone(), dt)
|
||||
}
|
||||
self.run_hook_on_one(hook, id, arg, dt)
|
||||
}
|
||||
|
||||
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) {
|
||||
/// Calls one lifecycle `hook` on object `id` (if the script defines it), then
|
||||
/// drains that object's ready actions into a fresh `Vec` and returns them —
|
||||
/// [`GameState`](crate::game::GameState) applies them immediately, before the
|
||||
/// next object runs, so each object sees the board state its actions run against.
|
||||
fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option<Dynamic>, dt: f64) -> Vec<BoardAction> {
|
||||
let mut actions = Vec::new();
|
||||
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
||||
if let Some(script_key) = info.script_name.as_ref()
|
||||
&& let Some(script) = self.scripts.get(script_key)
|
||||
@@ -250,29 +247,29 @@ impl ScriptHost {
|
||||
}
|
||||
// Run the drain regardless of if we have the hook, otherwise
|
||||
// things with no tick will never advance past a delay
|
||||
info.drain(self.board_queue.clone(), dt)
|
||||
info.drain(&mut actions, dt)
|
||||
}
|
||||
} else {
|
||||
unreachable!("Object not found");
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the
|
||||
/// hook. `dir` is the [`Direction`] the bump came *from* (it points from the
|
||||
/// bumped object toward the bumper). After the hook, drains the object's queue
|
||||
/// with `dt = 0`.
|
||||
pub fn run_bump(&mut self, id: ObjectId, dir: Direction) {
|
||||
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0);
|
||||
/// hook, and returns the actions it drained. `dir` is the [`Direction`] the
|
||||
/// bump came *from* (it points from the bumped object toward the bumper).
|
||||
pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
|
||||
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0)
|
||||
}
|
||||
|
||||
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
|
||||
/// hook, then drains its queue with `dt = 0`.
|
||||
/// hook, and returns the actions it drained.
|
||||
///
|
||||
/// Fired when the player walks onto a grab object or a grab object is pushed
|
||||
/// into the player (see [`GameState`](crate::game::GameState)). The hook
|
||||
/// typically increments a player stat and removes the object via `die()`.
|
||||
pub fn run_grab(&mut self, object_id: ObjectId) {
|
||||
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0);
|
||||
/// Fired when the player walks onto a grab object (see
|
||||
/// [`GameState`](crate::game::GameState)). The hook typically increments a
|
||||
/// player stat and removes the object via `die()`.
|
||||
pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec<BoardAction> {
|
||||
self.run_hook_on_one(Hook::Grab, object_id, None, 0.0)
|
||||
}
|
||||
|
||||
/// Calls the named function on the object with [`ObjectId`] `target_id`.
|
||||
@@ -282,8 +279,10 @@ impl ScriptHost {
|
||||
/// - If it has arity 1, we pass the ObjectInfo alone
|
||||
/// - If it has arity 0, we pass nothing
|
||||
///
|
||||
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`
|
||||
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) {
|
||||
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`.
|
||||
/// Returns the actions the call drained.
|
||||
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec<BoardAction> {
|
||||
let mut actions = Vec::new();
|
||||
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
||||
if let Some(script_key) = info.script_name.as_ref()
|
||||
&& let Some(script) = self.scripts.get(script_key)
|
||||
@@ -301,7 +300,7 @@ impl ScriptHost {
|
||||
// If it's not there at all, just bail:
|
||||
if arities.is_empty() {
|
||||
self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
||||
return
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Assemble the args
|
||||
@@ -324,16 +323,12 @@ impl ScriptHost {
|
||||
) {
|
||||
self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
||||
}
|
||||
info.drain(self.board_queue.clone(), 0.0)
|
||||
info.drain(&mut actions, 0.0)
|
||||
}
|
||||
} else {
|
||||
unreachable!("Object id not found, tried to send");
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes and returns the actions promoted onto the board queue.
|
||||
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
|
||||
std::mem::take(&mut self.board_queue.borrow_mut())
|
||||
actions
|
||||
}
|
||||
|
||||
/// Removes and returns the errors collected since the last drain.
|
||||
|
||||
@@ -46,7 +46,8 @@ fn bumping_a_transporter_drops_you_on_its_far_side() {
|
||||
game.run_init();
|
||||
|
||||
// Walk east into the transporter: it's solid so the player doesn't step onto
|
||||
// it, but the bump queues a teleport that the next tick applies.
|
||||
// it, but the bump fires a teleport that resolves within this try_move. The
|
||||
// trailing tick just advances the transporter's idle animation.
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_secs_f64(0.1));
|
||||
|
||||
|
||||
@@ -295,8 +295,8 @@ fn scroll_opens_on_player_bump() {
|
||||
)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move now, so the scroll is open immediately.
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
|
||||
let scroll = game
|
||||
.active_scroll
|
||||
@@ -318,11 +318,11 @@ fn handle_scroll_without_choice_clears_it() {
|
||||
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move, so the scroll is open right away.
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(game.active_scroll.is_some());
|
||||
|
||||
// No choice set — next tick clears the scroll without dispatching.
|
||||
// No choice set — a tick clears the scroll without dispatching.
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(game.active_scroll.is_none());
|
||||
}
|
||||
@@ -343,8 +343,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
)]),
|
||||
);
|
||||
game.run_init();
|
||||
// The bump resolves within try_move, so the scroll is open right away.
|
||||
game.try_move(Direction::East);
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(game.active_scroll.is_some());
|
||||
|
||||
// Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log.
|
||||
@@ -356,3 +356,53 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
|
||||
"eat() should have logged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_object_sees_an_earlier_objects_move_this_tick() {
|
||||
// The core of the epic: each object's queued actions apply immediately, before
|
||||
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East
|
||||
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0).
|
||||
// Because A already moved there this tick, B observes it as blocked. Under the
|
||||
// old collect-all-then-apply model B would have seen (1,0) still empty.
|
||||
let a = scripted_object(0, 0, "a");
|
||||
let b = scripted_object(1, 1, "b");
|
||||
let board = open_board(3, 2, (2, 1), vec![a, b]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
|
||||
game.tick(Duration::from_millis(16));
|
||||
|
||||
// A moved onto (1,0), and B saw it there the same tick.
|
||||
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0));
|
||||
assert_eq!(log_texts(&game), vec!["blocked"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_cycle_terminates_via_the_called_guard() {
|
||||
// Two objects send "go" to each other in a cycle. Without the per-invocation
|
||||
// "already-called" guard this would recurse forever; with it, each (object, fn,
|
||||
// args) fires at most once, so the cascade settles after one round-trip. That the
|
||||
// call returns at all — and logs exactly one "B" then one "A" — proves it.
|
||||
let a = scripted_object(0, 0, "a");
|
||||
let b = scripted_object(1, 0, "b");
|
||||
let board = open_board(3, 1, (2, 0), vec![a, b]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#),
|
||||
("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#),
|
||||
]),
|
||||
);
|
||||
|
||||
game.run_init();
|
||||
|
||||
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send);
|
||||
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
|
||||
assert_eq!(log_texts(&game), vec!["B", "A"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user