2026-07-25 23:20:52 -05:00
|
|
|
|
use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, SendArg};
|
2026-06-13 01:25:58 -05:00
|
|
|
|
use crate::board::Board;
|
2026-06-03 22:46:54 -05:00
|
|
|
|
use crate::log::LogLine;
|
2026-06-28 00:12:52 -05:00
|
|
|
|
use crate::script::ScriptHost;
|
2026-08-10 22:13:21 -05:00
|
|
|
|
use crate::utils::{Direction, ObjectId, Point, Pushable};
|
2026-06-13 01:25:58 -05:00
|
|
|
|
use crate::world::World;
|
|
|
|
|
|
use std::cell::{Ref, RefMut};
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use std::collections::{BTreeSet, HashSet, VecDeque};
|
2026-08-10 22:13:21 -05:00
|
|
|
|
use std::fmt::format;
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use std::hash::Hash;
|
2026-06-04 20:11:55 -05:00
|
|
|
|
use std::time::Duration;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
|
|
|
|
|
|
/// How long a `say()` speech bubble stays on screen, in seconds.
|
|
|
|
|
|
pub const SAY_DURATION: f64 = 3.0;
|
|
|
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
|
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
|
|
|
|
|
|
// accessing the private `action` module directly.
|
|
|
|
|
|
pub use crate::action::ScrollLine;
|
2026-07-07 23:55:27 -05:00
|
|
|
|
use crate::player::{Player, PlayerRef};
|
2026-07-24 21:52:05 -05:00
|
|
|
|
use crate::portal::Portal;
|
2026-07-25 23:20:52 -05:00
|
|
|
|
use crate::tile::{EnterResponse, Tile};
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
|
|
/// An active scroll overlay opened by a scripted object via `scroll()`.
|
|
|
|
|
|
///
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
pub struct Scroll {
|
|
|
|
|
|
/// The object whose `scroll()` call opened this overlay.
|
|
|
|
|
|
pub source: ObjectId,
|
|
|
|
|
|
/// The lines of content to display.
|
|
|
|
|
|
pub lines: Vec<ScrollLine>,
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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>,
|
2026-06-10 01:42:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 22:15:44 -05:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Holds the active game world and provides game-logic operations.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// `GameState` is the boundary between the engine (rendering, input) and the
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub struct GameState {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// All boards and world-level scripts.
|
|
|
|
|
|
world: World,
|
|
|
|
|
|
/// Key of the currently active board in [`world.boards`](World::boards).
|
|
|
|
|
|
current_board_name: String,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// The in-game message log, oldest first (newest pushed at the end).
|
|
|
|
|
|
pub log: Vec<LogLine>,
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// The Rhai scripting runtime for the active board's objects.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
scripts: ScriptHost,
|
2026-06-08 22:15:44 -05:00
|
|
|
|
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
|
|
|
|
|
|
pub speech_bubbles: Vec<SpeechBubble>,
|
2026-06-10 01:42:33 -05:00
|
|
|
|
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
pub active_scroll: Option<Scroll>,
|
2026-06-13 16:24:29 -05:00
|
|
|
|
/// 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>,
|
2026-06-25 19:16:46 -05:00
|
|
|
|
/// 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
|
2026-07-07 23:58:51 -05:00
|
|
|
|
/// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it.
|
2026-07-07 23:55:27 -05:00
|
|
|
|
pub player: PlayerRef,
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GameState {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
///
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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();
|
2026-07-07 23:55:27 -05:00
|
|
|
|
let player = Player::new_ref();
|
2026-06-13 01:25:58 -05:00
|
|
|
|
let host = ScriptHost::new(
|
2026-06-15 23:35:18 -05:00
|
|
|
|
world
|
|
|
|
|
|
.boards
|
|
|
|
|
|
.get(&name)
|
2026-07-07 23:55:27 -05:00
|
|
|
|
.expect("world::load guarantees start board exists").clone(),
|
|
|
|
|
|
player.clone(),
|
2026-06-13 01:25:58 -05:00
|
|
|
|
&world.scripts,
|
|
|
|
|
|
);
|
2026-06-04 20:11:55 -05:00
|
|
|
|
let mut state = Self {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
world,
|
|
|
|
|
|
current_board_name: name,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
log: Vec::new(),
|
2026-06-13 01:25:58 -05:00
|
|
|
|
scripts: host,
|
2026-06-08 22:15:44 -05:00
|
|
|
|
speech_bubbles: Vec::new(),
|
2026-06-10 01:42:33 -05:00
|
|
|
|
active_scroll: None,
|
2026-06-13 16:24:29 -05:00
|
|
|
|
board_transition: None,
|
2026-07-07 23:55:27 -05:00
|
|
|
|
player
|
2026-06-04 20:11:55 -05:00
|
|
|
|
};
|
2026-07-10 23:16:28 -05:00
|
|
|
|
state.drain_log();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
state
|
2026-06-03 22:46:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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(),
|
2026-07-11 14:47:08 -05:00
|
|
|
|
torch: crate::fov::SIGHT_RADIUS as u32,
|
2026-06-13 01:25:58 -05:00
|
|
|
|
scripts,
|
|
|
|
|
|
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
|
|
|
|
|
|
};
|
|
|
|
|
|
Self::from_world(world)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
|
|
|
|
|
pub fn board(&self) -> Ref<'_, Board> {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
self.world.boards[&self.current_board_name].borrow()
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Borrows the active board for mutation.
|
|
|
|
|
|
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
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
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 14:47:08 -05:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 18:43:29 -05:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// Appends a styled message to the log.
|
|
|
|
|
|
pub fn log(&mut self, line: LogLine) {
|
|
|
|
|
|
self.log.push(line);
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// 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.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
pub fn run_init(&mut self) {
|
2026-07-10 01:20:26 -05:00
|
|
|
|
// 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 ids = self.board().all_ids();
|
|
|
|
|
|
for id in ids {
|
|
|
|
|
|
let actions = self.scripts.run_init_on(id);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
self.apply_actions(actions);
|
2026-07-10 01:20:26 -05:00
|
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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.
|
2026-06-03 23:21:05 -05:00
|
|
|
|
pub fn tick(&mut self, dt: Duration) {
|
2026-06-15 09:15:30 -05:00
|
|
|
|
// 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();
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let secs = dt.as_secs_f64();
|
2026-07-10 01:20:26 -05:00
|
|
|
|
// Expire speech bubbles once per frame, before applying new actions so a
|
|
|
|
|
|
// fresh say() this frame isn't immediately culled.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.speech_bubbles.retain_mut(|b| {
|
|
|
|
|
|
b.remaining -= secs;
|
|
|
|
|
|
b.remaining > 0.0
|
|
|
|
|
|
});
|
2026-07-10 01:20:26 -05:00
|
|
|
|
// Run each object's tick in ascending id order, applying its drained
|
|
|
|
|
|
// actions immediately so the next object sees the updated board.
|
|
|
|
|
|
let ids = self.board().all_ids();
|
|
|
|
|
|
for id in ids {
|
|
|
|
|
|
let actions = self.scripts.run_tick_on(id, secs);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
self.apply_actions(actions);
|
2026-07-10 01:20:26 -05:00
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// 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) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
// TODO: errors are only logged for now. This is the place to halt execution /
|
|
|
|
|
|
// set an error state when a script faults.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
let lines = self.scripts.take_logs();
|
|
|
|
|
|
self.log.extend(lines);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
/// Applies one object's drained `actions` to the board
|
|
|
|
|
|
fn apply_actions(&mut self, actions: Vec<BoardAction>) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// 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();
|
2026-06-28 00:12:52 -05:00
|
|
|
|
|
2026-07-24 21:52:05 -05:00
|
|
|
|
for ba in actions {
|
|
|
|
|
|
match ba.action {
|
|
|
|
|
|
Action::Move(dir) => {
|
|
|
|
|
|
step_object(&mut self.board_mut(), ba.source, dir);
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::SetTile(tile) => {
|
|
|
|
|
|
if let Some(scr) = self.board_mut().scripting_mut(ba.source) {
|
|
|
|
|
|
scr.glyph.tile = tile;
|
2026-06-09 22:38:17 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
Action::SetLight(radius) => {
|
|
|
|
|
|
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
|
|
|
|
|
|
obj.optics.glow = radius;
|
2026-06-10 01:42:33 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
Action::SetTag {
|
|
|
|
|
|
target,
|
|
|
|
|
|
tag,
|
|
|
|
|
|
present,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
if let Some(obj) = self.board_mut().scripting_mut(target) {
|
|
|
|
|
|
if present {
|
|
|
|
|
|
obj.tags.insert(tag);
|
2026-07-08 10:06:25 -05:00
|
|
|
|
} else {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
obj.tags.remove(&tag);
|
2026-06-13 17:58:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
// 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) => {
|
|
|
|
|
|
// One bubble per object: replace the existing one if present.
|
|
|
|
|
|
self.speech_bubbles
|
|
|
|
|
|
.retain(|b| b.object_id != ba.source);
|
|
|
|
|
|
self.speech_bubbles.push(
|
|
|
|
|
|
SpeechBubble {
|
|
|
|
|
|
object_id: ba.source,
|
|
|
|
|
|
text,
|
|
|
|
|
|
remaining: duration,
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
// Delays are consumed by ScriptHost::drain and never reach the board queue.
|
|
|
|
|
|
Action::Delay(_) => {}
|
|
|
|
|
|
Action::SetColor { fg, bg } => {
|
|
|
|
|
|
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
|
|
|
|
|
|
if let Some(c) = fg {
|
|
|
|
|
|
obj.glyph.fg = c;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
if let Some(c) = bg {
|
|
|
|
|
|
obj.glyph.bg = c;
|
2026-07-11 11:47:45 -05:00
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
Action::Send { target, fn_name, arg} => {
|
|
|
|
|
|
self.scripts.run_send(target, &fn_name, arg);
|
2026-07-10 01:20:26 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
Action::Scroll(lines) => {
|
|
|
|
|
|
self.active_scroll.replace(Scroll {
|
|
|
|
|
|
source: ba.source,
|
|
|
|
|
|
lines,
|
|
|
|
|
|
choice: None,
|
|
|
|
|
|
});
|
2026-07-11 11:47:45 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
Action::Teleport { target, x, y } => {
|
|
|
|
|
|
apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e))
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::Push { x, y, dir } => {
|
2026-08-10 22:13:21 -05:00
|
|
|
|
self.resolve_move((x as usize, y as usize), dir);
|
|
|
|
|
|
//apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
|
2026-07-24 21:52:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
Action::Shift(cells) => {
|
|
|
|
|
|
apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e))
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::AddGems(n) => {
|
|
|
|
|
|
self.player.borrow_mut().alter_gems(n);
|
|
|
|
|
|
},
|
|
|
|
|
|
Action::AlterHealth(dh) => {
|
|
|
|
|
|
self.player.borrow_mut().alter_health(dh)
|
|
|
|
|
|
},
|
|
|
|
|
|
Action::SetKey(color, present) => {
|
|
|
|
|
|
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
|
|
|
|
|
|
log_sink.error(format!("set_key: unknown color {color:?}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
Action::Die => {
|
|
|
|
|
|
self.board_mut().remove_object(ba.source);
|
2026-07-10 01:20:26 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
self.drain_log();
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// Consumes the active scroll, dispatching the player's choice (if any) back
|
|
|
|
|
|
/// to the source object via `send`.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
///
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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
|
|
|
|
|
|
{
|
2026-07-10 01:20:26 -05:00
|
|
|
|
// Dispatch the choice back to the source object and apply whatever it
|
|
|
|
|
|
// queues (plus any bump/send cascade), the same as a tick.
|
2026-07-25 23:20:52 -05:00
|
|
|
|
self.scripts.run_send(scroll.source, &choice, SendArg::None);
|
2026-06-10 01:42:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:24:29 -05:00
|
|
|
|
/// 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) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.log.push(LogLine::error(format!(
|
|
|
|
|
|
"portal target board {target_map:?} not found"
|
|
|
|
|
|
)));
|
2026-06-13 16:24:29 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Find the named arrival portal on the target board (borrow then release).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let arrival = self.world.boards[target_map]
|
|
|
|
|
|
.borrow()
|
2026-07-24 21:52:05 -05:00
|
|
|
|
.named_portal(target_entry).map(Portal::location);
|
2026-06-13 16:24:29 -05:00
|
|
|
|
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;
|
2026-07-25 23:20:52 -05:00
|
|
|
|
// Switch to the new board
|
2026-06-13 16:24:29 -05:00
|
|
|
|
self.current_board_name = target_map.to_string();
|
2026-07-25 23:20:52 -05:00
|
|
|
|
// Clear any player instance that's already on the new board
|
|
|
|
|
|
let new_board_player_pos = self.board().player_pos();
|
|
|
|
|
|
self.board_mut().get_mut(new_board_player_pos.0, new_board_player_pos.1).take();
|
|
|
|
|
|
// place the player at the arrival portal.
|
2026-07-24 21:52:05 -05:00
|
|
|
|
self.board_mut().get_mut(ax, ay).replace(Tile::Player);
|
2026-07-06 00:41:38 -05:00
|
|
|
|
self.board_mut().clear_all_queues();
|
2026-06-13 16:24:29 -05:00
|
|
|
|
// Rebuild the script host for the new board's objects.
|
|
|
|
|
|
self.scripts = ScriptHost::new(
|
2026-07-07 23:55:27 -05:00
|
|
|
|
self.world.boards[&self.current_board_name].clone(),
|
|
|
|
|
|
self.player.clone(),
|
2026-06-13 16:24:29 -05:00
|
|
|
|
&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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 22:13:21 -05:00
|
|
|
|
fn resolve_move(&mut self, from: (usize, usize), dir: Direction) -> bool {
|
|
|
|
|
|
// Get the target coords, if they're out of bounds then the move fails.
|
|
|
|
|
|
let target: Point = dir.from_point(from.0 as i64, from.1 as i64).into();
|
|
|
|
|
|
if !self.board().in_bounds((target.x, target.y)) { return false; }
|
|
|
|
|
|
|
|
|
|
|
|
if self.board().is_empty(target) {
|
|
|
|
|
|
// If it's empty, we can move in there; do so and return true:
|
|
|
|
|
|
self.board_mut().move_cell(from.into(), target);
|
|
|
|
|
|
} else if self.board().is_player(target) {
|
|
|
|
|
|
// Target cell contains player, who's pushable, so we'll recurse and see what happens:
|
|
|
|
|
|
if self.resolve_move((target.x as usize, target.y as usize), dir) {
|
|
|
|
|
|
self.board_mut().move_cell(from.into(), target);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Otherwise, what enter response does the thing there have? We need to be able to call
|
|
|
|
|
|
// hooks on objects, so we can't hold a mut reference to the board going into this match
|
|
|
|
|
|
let id = {
|
|
|
|
|
|
if let Some(Tile::Object(b)) = self.board_mut().get(target.x as usize, target.y as usize) && let def = b.as_ref() {
|
|
|
|
|
|
def.scripting.id
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unreachable!("moving into the player was handled above")
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Whether the player is what initiated the move directly. Matters for grab / swap
|
|
|
|
|
|
// let from_player = matches!(self.board().get(from.0, from.1), Some(Tile::Player));
|
|
|
|
|
|
|
|
|
|
|
|
// Call the target's bump hook and resolve whatever it did
|
|
|
|
|
|
let actions = self.scripts.run_bump(id, dir.opposite());
|
|
|
|
|
|
self.apply_actions(actions);
|
|
|
|
|
|
|
|
|
|
|
|
// First do things to try and call any hooks relevant:
|
|
|
|
|
|
// let actions = match resp {
|
|
|
|
|
|
// // We block all moves, return false
|
|
|
|
|
|
// EnterResponse::Block => { self.scripts.run_bump(id, dir.opposite()) }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // The player can grab things directly, so let's do that
|
|
|
|
|
|
// EnterResponse::Grab if from_player => {
|
|
|
|
|
|
// let a = self.scripts.run_grab(id);
|
|
|
|
|
|
// self.board_mut().get_mut(target.0, target.1).take();
|
|
|
|
|
|
// a
|
|
|
|
|
|
// }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Grabbables not from the player act as pushable, we need to recurse
|
|
|
|
|
|
// EnterResponse::Grab => { self.resolve_move(target, dir); vec![] }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Pushable if we're allowed to push that way recurses
|
|
|
|
|
|
// EnterResponse::Push(p) if p.allows(dir) => { self.resolve_move(target, dir); vec![] }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Pushable in a disallowed direction blocks
|
|
|
|
|
|
// EnterResponse::Push(p) => { self.scripts.run_bump(id, dir.opposite()) }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Swaps let the player swap places, but we should return false afterward because we
|
|
|
|
|
|
// // haven't left the source cell empty. In practice this won't matter because right
|
|
|
|
|
|
// // now we never recurse _into_ a player-source-cell, all moves are initiated by the
|
|
|
|
|
|
// // player, but still.
|
|
|
|
|
|
// EnterResponse::Swap if from_player => {
|
|
|
|
|
|
// let mut board = self.board_mut();
|
|
|
|
|
|
// let mover = board.get_mut(from.0, from.1).take();
|
|
|
|
|
|
// let swapper = board.get_mut(target.0, target.1).take();
|
|
|
|
|
|
// board.get_mut(target.0, target.1).replace(mover.unwrap());
|
|
|
|
|
|
// board.get_mut(from.0, from.1).replace(swapper.unwrap());
|
|
|
|
|
|
// vec![]
|
|
|
|
|
|
// }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Swaps in a chain are just normal pushable, recurse:
|
|
|
|
|
|
// EnterResponse::Swap => { self.resolve_move(target, dir); vec![] }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Squish just lets the mover overwrite, and always leaves the source cell empty:
|
|
|
|
|
|
// EnterResponse::Squish => { self.board_mut().get_mut(from.0, from.1).take(); vec![] }
|
|
|
|
|
|
//
|
|
|
|
|
|
// // Finally, hook, we need to call a hook and let it do its thing:
|
|
|
|
|
|
// EnterResponse::Hook => { self.scripts.run_bump(id, dir.opposite()) }
|
|
|
|
|
|
// };
|
|
|
|
|
|
|
|
|
|
|
|
// If there were actions performed by the hooks, do them.
|
|
|
|
|
|
// self.apply_actions(actions);
|
|
|
|
|
|
|
|
|
|
|
|
// Now, check again if the target cell is empty. If it is, put the mover there. Also
|
|
|
|
|
|
// check that the source cell still contains something! It may have been teleported away.
|
|
|
|
|
|
if !self.board().is_empty(from.into()) && self.board().is_empty(target) {
|
|
|
|
|
|
self.board_mut().move_cell(from.into(), target);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Either way, return whether the source cell is now empty, so calls up the chain
|
|
|
|
|
|
// can move into it (push chains)
|
|
|
|
|
|
self.board().is_empty(from.into())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// Attempts to move the player one cell in `dir`.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// The move is ignored if the target cell is out of bounds, or it is neither
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
|
2026-07-08 13:21:27 -05:00
|
|
|
|
/// 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).
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pub fn try_move(&mut self, dir: Direction) {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
|
|
|
|
|
let player_loc = self.board().player_pos();
|
|
|
|
|
|
let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy);
|
|
|
|
|
|
if !self.board().in_bounds(target) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 22:13:21 -05:00
|
|
|
|
self.resolve_move(player_loc, dir);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
|
|
|
|
|
|
// Check if we actually moved
|
|
|
|
|
|
let new_loc = self.board().player_pos();
|
|
|
|
|
|
if new_loc != player_loc {
|
|
|
|
|
|
// Portals take priority: if we're on a portal it doesn't matter what else we entered:
|
|
|
|
|
|
let portal_info = {
|
|
|
|
|
|
if let Some(Portal { target_board, target_name, ..}) = self.board().portal_at(new_loc.0, new_loc.1) {
|
|
|
|
|
|
Some((target_board.clone(), target_name.clone()))
|
|
|
|
|
|
} else { None }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if let Some((target_board, target_name)) = portal_info {
|
|
|
|
|
|
self.enter_board(&target_board, &target_name)
|
2026-06-13 16:24:29 -05:00
|
|
|
|
} else {
|
2026-07-24 21:52:05 -05:00
|
|
|
|
// We're still on the board, so see if we stepped on any sensors:
|
|
|
|
|
|
let sensor_ids = self.board().sensor_ids_at(new_loc.0, new_loc.1);
|
|
|
|
|
|
for id in sensor_ids {
|
|
|
|
|
|
let actions = self.scripts.run_enter(id, dir.opposite());
|
|
|
|
|
|
self.apply_actions(actions)
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
2026-06-06 14:11:20 -05:00
|
|
|
|
}
|
2026-07-24 21:52:05 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
self.drain_log();
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
|
|
2026-07-11 11:47:45 -05:00
|
|
|
|
/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter`
|
|
|
|
|
|
/// reactions for the caller to resolve after the board borrow drops.
|
2026-06-06 17:36:00 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// 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
|
2026-07-08 13:21:27 -05:00
|
|
|
|
/// 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
|
2026-07-11 11:47:45 -05:00
|
|
|
|
/// 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).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) {
|
|
|
|
|
|
// TODO when an object pushes the player, it should still trigger actions on
|
|
|
|
|
|
// what the player is pushed into. But, for right now, just call board::push
|
|
|
|
|
|
let (loc, solid) = if let Some(obj) = board.get_hookable(id) {
|
|
|
|
|
|
(obj.location(), obj.solid())
|
|
|
|
|
|
} else { return };
|
|
|
|
|
|
|
|
|
|
|
|
if solid {
|
|
|
|
|
|
// This is a real object on the board, try and push it
|
2026-07-25 12:31:13 -05:00
|
|
|
|
board.move_object(loc.0, loc.1, dir);
|
2026-07-24 21:52:05 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
// This is a sensor, we can just teleport it
|
|
|
|
|
|
board.move_sensor(id, dir);
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::GameState;
|
2026-06-21 18:27:45 -05:00
|
|
|
|
use crate::Direction;
|
2026-07-25 00:09:53 -05:00
|
|
|
|
use crate::board::tests::{
|
|
|
|
|
|
builtin_at, crate_at, gem_at, is_builtin, open_board, sensor_at, wall_at,
|
|
|
|
|
|
};
|
2026-06-21 01:32:47 -05:00
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn walking_onto_a_gem_grabs_it() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// A gem builtin object at (1,0), running scripts/gem.rhai. Its
|
|
|
|
|
|
// EnterResponse::Grab means the player's step fires grab() rather than being
|
|
|
|
|
|
// blocked. The player starts at (0,0).
|
2026-07-24 21:52:05 -05:00
|
|
|
|
let mut board = open_board(3, 1, (0, 0));
|
|
|
|
|
|
gem_at(&mut board, 1, 0);
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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.
|
2026-07-07 23:55:27 -05:00
|
|
|
|
assert_eq!(game.player.borrow().gems, 1);
|
2026-07-25 00:09:53 -05:00
|
|
|
|
assert!(game.board().all_ids().is_empty());
|
2026-07-24 21:52:05 -05:00
|
|
|
|
assert_eq!(game.board().player_pos(), (1, 0));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 00:09:53 -05:00
|
|
|
|
/// Builds a `GameState` with a script-only sensor at (0,0) running `src`, a crate
|
|
|
|
|
|
/// at (2,0), and the player parked against the right-hand edge.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The script host is a [`Sensor`](crate::tile::Sensor) rather than an object:
|
|
|
|
|
|
/// every grid object is solid now, so a scripted thing that must not interfere
|
|
|
|
|
|
/// with movement has to live off-grid.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board = open_board(board_w, 1, (board_w - 1, 0));
|
|
|
|
|
|
sensor_at(&mut board, 0, 0, "s");
|
2026-06-21 01:32:47 -05:00
|
|
|
|
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]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn script_shift_rotates_crates() {
|
|
|
|
|
|
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// crate moves to (3,0), empty moves back to (2,0). No queue guard is needed —
|
|
|
|
|
|
// the host only calls `tick` when the object's queue has drained.
|
|
|
|
|
|
let mut game = game_with_object_script(5, "fn tick(me, dt) { shift([[2, 0], [3, 0]]); }");
|
2026-06-21 01:32:47 -05:00
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
let b = game.board();
|
2026-07-25 00:09:53 -05:00
|
|
|
|
assert!(b.get(2, 0).is_none());
|
|
|
|
|
|
assert!(is_builtin(&b, 3, 0, "crate"));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn script_passable_distinguishes_empty_solid_and_offboard() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The script
|
|
|
|
|
|
// sensor sits at (0,0) — sensors never occupy a cell, so it does not make its
|
|
|
|
|
|
// own cell impassable; the player is at (3,0). `passable` should report only
|
|
|
|
|
|
// the genuinely empty in-bounds cell.
|
|
|
|
|
|
let mut board = open_board(4, 1, (3, 0));
|
|
|
|
|
|
sensor_at(&mut board, 0, 0, "s");
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 2, 0);
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let src = "fn init(me) { \
|
2026-07-07 23:55:27 -05:00
|
|
|
|
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\" }); }";
|
2026-06-21 01:32:47 -05:00
|
|
|
|
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"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn log_is_immediate_and_not_paced_by_the_queue() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// `delay(5.0)` parks the sensor's action queue for 5 seconds, then `log()`
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// 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.
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board = open_board(4, 1, (3, 0));
|
|
|
|
|
|
sensor_at(&mut board, 0, 0, "s");
|
|
|
|
|
|
let src = "fn init(me) { delay(5.0); log(\"immediate\"); }";
|
2026-07-10 23:16:28 -05:00
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 00:09:53 -05:00
|
|
|
|
/// Builds a board with a clockwise `spinner_cw` builtin at (1,1), plus crates and
|
|
|
|
|
|
/// walls at the given ring cells.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
|
|
|
|
|
|
spinner_board_dir(crates, walls, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 00:09:53 -05:00
|
|
|
|
/// Like [`spinner_board`] but `ccw` stamps the `spinner_ccw` alias instead, so the
|
|
|
|
|
|
/// shared `scripts/spinner.rhai` reads its `BUILTIN_spinner_ccw` tag and spins the
|
|
|
|
|
|
/// other way (clockwise is the default when the tag is absent).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The board is 4×3, not 3×3: the spinner is a solid object now, so the player
|
|
|
|
|
|
/// needs a cell of its own at (3,1) instead of being parked on the spinner, and
|
|
|
|
|
|
/// the 8-cell ring around (1,1) must stay fully in bounds or `apply_shift`
|
|
|
|
|
|
/// rejects the whole rotation. Ring coordinates are unchanged from the 3×3
|
|
|
|
|
|
/// layout — N(1,0) NE(2,0) E(2,1) SE(2,2) S(1,2) SW(0,2) W(0,1) NW(0,0).
|
2026-06-21 01:32:47 -05:00
|
|
|
|
fn spinner_board_dir(
|
|
|
|
|
|
crates: &[(usize, usize)],
|
|
|
|
|
|
walls: &[(usize, usize)],
|
|
|
|
|
|
ccw: bool,
|
|
|
|
|
|
) -> GameState {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board = open_board(4, 3, (3, 1));
|
|
|
|
|
|
// Stamped first, so the spinner holds the lowest id and ticks before anything else.
|
|
|
|
|
|
builtin_at(&mut board, 1, 1, if ccw { "spinner_ccw" } else { "spinner_cw" });
|
2026-06-21 01:32:47 -05:00
|
|
|
|
for &(x, y) in crates {
|
|
|
|
|
|
crate_at(&mut board, x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
for &(x, y) in walls {
|
|
|
|
|
|
wall_at(&mut board, x, y);
|
|
|
|
|
|
}
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// No world script pool: a builtin carries its own ScriptKey and source.
|
|
|
|
|
|
let mut game = GameState::new(board);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
game.run_init();
|
|
|
|
|
|
game
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn spinner_does_not_destroy_a_blocked_neighbour() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). The wall is immobile, and
|
|
|
|
|
|
// `apply_shift` cascades that backward over the contiguous run of solids
|
|
|
|
|
|
// behind it, so N must not rotate onto NE — an earlier one-cell-lookahead
|
|
|
|
|
|
// version overwrote and destroyed NE's crate. Everything stays put.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
let b = game.board();
|
2026-07-25 00:09:53 -05:00
|
|
|
|
assert!(is_builtin(&b, 1, 0, "crate")); // N kept
|
|
|
|
|
|
assert!(is_builtin(&b, 2, 0, "crate")); // NE kept (not destroyed)
|
|
|
|
|
|
assert!(is_builtin(&b, 2, 1, "wall")); // wall kept
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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();
|
2026-07-25 00:09:53 -05:00
|
|
|
|
assert!(is_builtin(&b, 2, 0, "crate")); // NE: filled by N
|
|
|
|
|
|
assert!(is_builtin(&b, 1, 0, "crate")); // N: filled by NW
|
|
|
|
|
|
assert!(is_builtin(&b, 0, 0, "crate")); // NW: filled by W
|
|
|
|
|
|
assert!(b.get(0, 1).is_none()); // W: vacated (hole moved here)
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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();
|
2026-07-25 00:09:53 -05:00
|
|
|
|
assert!(is_builtin(&b, 0, 0, "crate")); // NW: crate rotated counter-clockwise
|
|
|
|
|
|
assert!(b.get(2, 0).is_none()); // NE: untouched
|
|
|
|
|
|
assert!(b.get(1, 0).is_none()); // N: vacated
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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(&[], &[]);
|
2026-07-25 00:09:53 -05:00
|
|
|
|
// With no crates or walls the spinner is the board's only object; the player
|
|
|
|
|
|
// is a `Tile::Player`, which carries no id.
|
|
|
|
|
|
let id = game.board().all_ids()[0];
|
|
|
|
|
|
let glyph_of = |game: &GameState| {
|
|
|
|
|
|
game.board()
|
|
|
|
|
|
.get_hookable(id)
|
|
|
|
|
|
.expect("the spinner stays on the board")
|
|
|
|
|
|
.glyph()
|
2026-06-21 01:32:47 -05:00
|
|
|
|
};
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let base = glyph_of(&game);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut seq = Vec::new();
|
|
|
|
|
|
for _ in 0..5 {
|
|
|
|
|
|
game.tick(Duration::from_secs_f64(0.5));
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let g = glyph_of(&game);
|
|
|
|
|
|
seq.push(g.tile);
|
|
|
|
|
|
assert_eq!(g.fg, base.fg, "fg unchanged");
|
|
|
|
|
|
assert_eq!(g.bg, base.bg, "bg unchanged");
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
2026-07-26 23:29:43 -05:00
|
|
|
|
assert_eq!(seq, vec!['/', '─', '\\', '│', '/']);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
2026-06-23 20:07:53 -05:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn set_key_gives_and_takes_keys() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board = open_board(2, 1, (1, 0));
|
|
|
|
|
|
sensor_at(&mut board, 0, 0, "s");
|
2026-06-23 20:07:53 -05:00
|
|
|
|
let scripts = HashMap::from([(
|
|
|
|
|
|
"s".to_string(),
|
2026-07-25 00:09:53 -05:00
|
|
|
|
r#"fn init(me) { set_key("blue", true); set_key("red", true); }"#.to_string(),
|
2026-06-23 20:07:53 -05:00
|
|
|
|
)]);
|
|
|
|
|
|
let mut game = GameState::with_scripts(board, scripts);
|
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
|
2026-07-07 23:55:27 -05:00
|
|
|
|
let keys = game.player.borrow().keys;
|
|
|
|
|
|
assert!(keys.blue);
|
|
|
|
|
|
assert!(keys.red);
|
|
|
|
|
|
assert!(!keys.cyan); // cyan was not set by the script
|
2026-06-23 20:07:53 -05:00
|
|
|
|
|
|
|
|
|
|
// A second script can take a key.
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board2 = open_board(2, 1, (1, 0));
|
|
|
|
|
|
sensor_at(&mut board2, 0, 0, "t");
|
2026-06-23 20:07:53 -05:00
|
|
|
|
let scripts2 = HashMap::from([(
|
|
|
|
|
|
"t".to_string(),
|
2026-07-25 00:09:53 -05:00
|
|
|
|
r#"fn init(me) { set_key("blue", true); set_key("blue", false); }"#.to_string(),
|
2026-06-23 20:07:53 -05:00
|
|
|
|
)]);
|
|
|
|
|
|
let mut game2 = GameState::with_scripts(board2, scripts2);
|
|
|
|
|
|
game2.run_init();
|
|
|
|
|
|
|
2026-07-07 23:55:27 -05:00
|
|
|
|
assert!(!game2.player.borrow().keys.blue);
|
2026-06-23 20:07:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn set_key_unknown_color_logs_error() {
|
2026-07-25 00:09:53 -05:00
|
|
|
|
let mut board = open_board(2, 1, (1, 0));
|
|
|
|
|
|
sensor_at(&mut board, 0, 0, "s");
|
2026-06-23 20:07:53 -05:00
|
|
|
|
let scripts = HashMap::from([(
|
|
|
|
|
|
"s".to_string(),
|
2026-07-25 00:09:53 -05:00
|
|
|
|
r#"fn init(me) { set_key("chartreuse", true); }"#.to_string(),
|
2026-06-23 20:07:53 -05:00
|
|
|
|
)]);
|
|
|
|
|
|
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"))));
|
|
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|