diff --git a/bin/raptor-rust b/bin/raptor-rust index 7356eca8..df707470 100755 Binary files a/bin/raptor-rust and b/bin/raptor-rust differ diff --git a/bin/raptor-rust.exe b/bin/raptor-rust.exe index 4f49ebb2..d5303c53 100755 Binary files a/bin/raptor-rust.exe and b/bin/raptor-rust.exe differ diff --git a/docs/modding-api.md b/docs/modding-api.md index 3cb60adb..1070ee23 100644 --- a/docs/modding-api.md +++ b/docs/modding-api.md @@ -159,7 +159,7 @@ fn on_fire(event, state, random) { | `on_mission_start` | Mission: `tick = 0`. Runs when the first simulation step dispatches events. | | `on_mission_end` | Mission: `success`, `tick`. | -Every dispatched event also includes `tick`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, `width`, `height`, health, animation, movement state and the definition's `ground` boolean. Filter on `ground` when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state. +Every World-dispatched event also includes `tick`, `difficulty`, `actor`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `difficulty` is the active World difficulty as an integer: `0` Training, `1` Rookie, `2` Veteran, `3` Elite. It is supplied centrally at dispatch for every hook, including events raised by commands; scripts can use it to select their own patterns. It does not change spawn difficulty gates or add a command that changes difficulty. Pilot saves retain difficulty alongside script state and RNG; recreating a mission from that pilot supplies the restored difficulty to its callbacks. `actor` is the addressed enemy's current snapshot, with exactly the same fields as its `entities` entry, including `ground`. It is `null` for mission callbacks, player entity `0`, nonenemy addresses and enemies already removed. A zero-health enemy still present in the World has an actor snapshot with `health = 0`; scripts should check presence and health before issuing commands. The actor is resolved when the callback dispatches, so it reflects movement, damage or removal committed by earlier callbacks; event-local `x`, `y` or `health` can still describe the earlier queued event. Scripts can read `event.actor` directly instead of scanning `event.entities` for themselves. This reuses the existing enemy-snapshot traversal and does not add another enemy scan or change the cost of constructing `entities`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, `width`, `height`, health, animation, movement state and the definition's `ground` boolean. Filter on `ground` when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state. Use `behavior: "scripted"` for hitscan, chain lightning, proximity zaps or other attacks implemented entirely with commands. Player selection, firing input and weapon cooldowns still apply. An owned item with `special: false` fires its weapon alongside other ordinary equipment when Fire is held; `special: true` makes it selectable. Neither player nor enemy scripted weapons receive a default engine projectile. A script may explicitly emit `projectile`, or pair `damage` with `arc` for an immediate visible discharge. `target` describes the weapon's capability for the shop and governs explicit projectiles; scripts must enforce the intended targets and range for their own `damage` commands. @@ -216,7 +216,7 @@ Lightning arcs render in both SD and HD using irregular fractal channels, thin t `anchor` attaches the starting endpoint, and `to_anchor` independently attaches the destination. Supply absolute world coordinates at creation; each endpoint preserves its offset from the anchor's top-left `x/y` origin as the ship moves. Omitting an anchor keeps that endpoint fixed. Both anchors may be `0` to make a ring segment follow the player, or use `anchor: 0` and `to_anchor: enemy.entity` for a ship-to-ship bolt. Anchor IDs must exist when the command runs; a missing anchor rejects the batch. Endpoints update after simulation movement and API movement commands, and freeze at their last position when the anchored enemy disappears. Cosmetic coordinates clamp to ±32,768 when an attached ship moves beyond those bounds. -Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; maps of 1,024 entries; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. Scripts cannot increase these limits. +Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; object maps with at most 16,384 aggregate properties across their nested map tree; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. [Rhai counts nested properties recursively](https://rhai.rs/book/safety/max-map-size.html), so the map bound accommodates complete enemy/projectile snapshots rather than only a map's top-level fields. The separate 64 KiB serialized callback-state and command/operation bounds remain unchanged. Scripts cannot increase these limits. ## Saves and compatibility diff --git a/docs/resume-checkpoint.md b/docs/resume-checkpoint.md index c3203e43..cfbb821c 100644 --- a/docs/resume-checkpoint.md +++ b/docs/resume-checkpoint.md @@ -15,6 +15,14 @@ Receipts for this one-picture append are in `.reference/hd-root-next-publication-after5339/`; immutable intake is `handoff-5340-20261017`. +The included Linux and Windows runtimes now expose active script difficulty and +current enemy snapshots. A finite 16,384-property Rhai map limit fixes documented +queries failing on ordinary enemy groups; operation, command and 64 KiB saved-state +limits remain unchanged. All 45 modding regression tests pass. Three actual 1080p +SDL captures (menu, S1 Wave 7, S3 Wave 9) match the previous binary byte for byte. +Windows cross-build, PE imports and shared-data verification pass; this source +revision still needs its own native Windows CI result. + Iron Coast retuning is active in `.reference/campaign-retune-20261017/`. The baseline covers all 27 original and 14 mod missions at all four difficulties. Mission waves, enemy movement, bosses and weapon presentation are being validated diff --git a/docs/rust-rewrite.md b/docs/rust-rewrite.md index 8bce7ee2..7b47ff9e 100644 --- a/docs/rust-rewrite.md +++ b/docs/rust-rewrite.md @@ -1,5 +1,19 @@ # Rust engine and validation +## 17 October 2026: live mod snapshots and dense-event queries + +World callbacks now receive `difficulty` and the addressed enemy's current `actor` +snapshot, including damage, movement and removal committed by earlier callbacks. +Dense enemy snapshots exposed Rhai's recursive map limit: even an ordinary query +could reject a 24-enemy event. The bounded limit is now 16,384 properties; existing +operation, command and serialized-state limits remain unchanged. + +All 45 modding tests pass, including all four difficulties, save/RNG continuation, +stale callbacks, 64-enemy queries and oversized intermediate/output rejection. +Linux and Windows executables are rebuilt. Menu and two original-mission 1080p +SDL captures are byte-identical across the change (6,220,800 pixels compared). +Windows execution is verified separately by the post-push native workflow. + Rust is the default engine. The Rust binary never calls or embeds the C++ engine. SDL2 provides window/input/controller/audio services. Simulation and the validation CLI build without SDL2. The retained C++ checkout is optional reference material. | Module | Responsibility | diff --git a/runtime-manifest.json b/runtime-manifest.json index 3b9e31fc..3b0faa45 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -3,7 +3,7 @@ "platform": "Linux x86_64", "catalog_entries": 6449, "approved_hd_frames": 5340, - "binary_sha256": "ff6a6f08586a7950ee6bfcaba14da89409df63fbbd584d4255efc2b2ecff836f", + "binary_sha256": "b0e5f424cb60301f34e5e0088a9d00ad10ae609cd70b4e72be560376d952ed83", "data_files": 9324, "data_bytes": 8153633623, "files": { @@ -9333,11 +9333,11 @@ "data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff" }, "system_dependencies": [ - "\tlinux-vdso.so.1 (0x00007f8e2614b000)", - "\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f8e25400000)", - "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f8e260f7000)", - "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f8e256c9000)", - "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f8e25000000)", - "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f8e2614d000)" + "\tlinux-vdso.so.1 (0x00007f7cf6301000)", + "\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f7cf5400000)", + "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f7cf62ad000)", + "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f7cf6176000)", + "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f7cf5000000)", + "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f7cf6303000)" ] } diff --git a/runtime-windows-manifest.json b/runtime-windows-manifest.json index f17987a7..8da0523e 100644 --- a/runtime-windows-manifest.json +++ b/runtime-windows-manifest.json @@ -37,7 +37,7 @@ ] }, "files": { - "bin/raptor-rust.exe": "427140b2b8b773979db46d9647728835af529082bee8503e6fa4eaaae37d4bee", + "bin/raptor-rust.exe": "28826f75f8952ac624f5058161e73d1a7c78a9ecc38f0e95635b327367ffba57", "bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa", "bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127", "run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269" diff --git a/src/modding.rs b/src/modding.rs index 837232af..c3f31463 100644 --- a/src/modding.rs +++ b/src/modding.rs @@ -708,7 +708,10 @@ impl ScriptRuntime { .set_max_call_levels(32) .set_max_expr_depths(64, 32) .set_max_array_size(4096) - .set_max_map_size(1024) + // Rhai counts nested map properties, including host event snapshots. + // A 64-enemy event already has 2,886 properties. Keep inputs bounded + // without relaxing the separate 64 KiB state or operation limits. + .set_max_map_size(16_384) .set_max_string_size(16384) .set_max_modules(0); engine.disable_symbol("eval"); diff --git a/src/simulation.rs b/src/simulation.rs index e234ce7e..9412dfb5 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -2887,6 +2887,15 @@ impl World { if let Some(path) = script { let owner = ®istry.owners[&event.content]; let mut e = event.event; + e["difficulty"] = self.difficulty.into(); + let actor_entity = if registry.missions.contains_key(&event.content) { + None + } else { + e.get("entity") + .and_then(serde_json::Value::as_u64) + .filter(|entity| *entity != 0) + }; + let mut actor = serde_json::Value::Null; e["entities"] = serde_json::to_value(&self.enemies)?; for (snapshot, enemy) in e["entities"] .as_array_mut() @@ -2895,7 +2904,11 @@ impl World { .zip(&self.enemies) { snapshot["ground"] = registry.enemies[&enemy.definition].ground.into(); + if actor_entity == Some(enemy.entity) { + actor = snapshot.clone(); + } } + e["actor"] = actor; e["player"] = serde_json::to_value(&self.player)?; e["objectives"] = serde_json::to_value(&self.objectives)?; e["projectiles"] = serde_json::to_value(&self.projectiles)?; diff --git a/tests/modding.rs b/tests/modding.rs index 9d38aa9a..ac341190 100644 --- a/tests/modding.rs +++ b/tests/modding.rs @@ -1055,6 +1055,259 @@ fn callback_arity_and_source_size_are_checked_before_activation() { ); } +#[test] +fn every_dispatched_hook_receives_the_active_world_difficulty() { + let hooks = [ + ("base:sector_1_wave_1", "on_mission_start"), + ("base:sector_1_wave_1", "on_mission_end"), + ("base:sector_1_wave_1", "on_tick"), + ("base:probe", "on_spawn"), + ("base:probe", "on_tick"), + ("base:probe", "on_hit"), + ("base:probe", "on_death"), + ("base:projectile_forward_guns", "on_fire"), + ("base:energy", "on_pickup"), + ("base:energy", "on_use"), + ]; + let callbacks = [ + "on_mission_start", "on_mission_end", "on_tick", "on_spawn", + "on_hit", "on_death", "on_fire", "on_pickup", "on_use", + ]; + let source = callbacks.iter().map(|hook| format!( + "fn {hook}(e,s,r) {{ s[\"{hook}\"] = e.difficulty; #{{commands:[],state:s}} }}" + )).collect::>().join("\n"); + for difficulty in 0..=3 { + let mut registry = base(); + registry.apply(pack(json!({"enemies":[{ + "id":"base:probe","name":"Probe","health":10,"speed":0, + "script":"difficulty.rhai" + }]})), "base").unwrap(); + registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script = + Some("difficulty.rhai".into()); + registry.weapons.get_mut(&ContentId::from("base:projectile_forward_guns")).unwrap().script = + Some("difficulty.rhai".into()); + registry.items.get_mut(&ContentId::from("base:energy")).unwrap().script = + Some("difficulty.rhai".into()); + let mut runtime = ScriptRuntime::new(); + runtime.add("base", "difficulty.rhai", &source).unwrap(); + let pilot = Pilot { difficulty, ..Pilot::default() }; + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + // Exercise the real dispatcher with explicit hook fixtures, including + // directly queued events. The common World snapshot must override an + // event-local value rather than relying on individual event producers. + world.events.extend(hooks.iter().map(|&(content, hook)| { + raptor_replica::simulation::HookEvent { + content: content.into(), hook, event: json!({"difficulty":99}), + } + })); + world.step(Input::default(), ®istry, &mut runtime).unwrap(); + for hook in callbacks { + assert_eq!(runtime.states["base/difficulty.rhai"][hook], difficulty, + "{hook} must observe active difficulty {difficulty}"); + } + } +} + +#[test] +fn saved_pilot_difficulty_and_callback_state_rng_resume_together() { + let source = r#"fn on_tick(e,s,r) { + let n = if s.contains("n") { s.n + 1 } else { 1 }; + #{commands:[],state:#{n:n,difficulty:e.difficulty,last:r[0]}} + }"#; + for difficulty in 0..=3 { + let mut registry = base(); + registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script = + Some("difficulty.rhai".into()); + let mut pilot = Pilot { difficulty, ..Pilot::default() }; + let mut runtime = ScriptRuntime::new(); + runtime.add("base", "difficulty.rhai", source).unwrap(); + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + for _ in 0..3 { + world.step(Input::default(), ®istry, &mut runtime).unwrap(); + } + pilot.merge_runtime_snapshot(&[], &runtime.states, &runtime.rng); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pilot.json"); + pilot.save(&path).unwrap(); + let saved = Pilot::load(&path, ®istry, &[]).unwrap(); + assert_eq!(saved.difficulty, difficulty); + let mut resumed_runtime = ScriptRuntime::new(); + resumed_runtime.add("base", "difficulty.rhai", source).unwrap(); + resumed_runtime.states = saved.script_state.clone(); + resumed_runtime.rng = saved.script_rng.clone(); + // Pilot saves recreate a mission; they do not store a live World. + // The next callback still continues script state/RNG and receives the + // difficulty restored into the new World by World::new. + let mut resumed_world = World::new(&saved.mission, &saved, ®istry).unwrap(); + resumed_world.step(Input::default(), ®istry, &mut resumed_runtime).unwrap(); + world.step(Input::default(), ®istry, &mut runtime).unwrap(); + assert_eq!(resumed_runtime.states, runtime.states); + assert_eq!(resumed_runtime.rng, runtime.rng); + assert_eq!(resumed_runtime.states["base/difficulty.rhai"]["n"], 4); + assert_eq!(resumed_runtime.states["base/difficulty.rhai"]["difficulty"], difficulty); + } +} + +#[test] +fn callback_actor_is_current_after_earlier_damage_move_and_despawn() { + let mut registry = base(); + registry.apply(pack(json!({"enemies":[{ + "id":"base:probe","name":"Probe","health":10,"speed":0, + "ground":true,"script":"actor.rhai" + }]})), "base").unwrap(); + registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script = + Some("actor.rhai".into()); + registry.weapons.get_mut(&ContentId::from("base:projectile_forward_guns")).unwrap().script = + Some("actor.rhai".into()); + let mut runtime = ScriptRuntime::new(); + runtime.add("base", "actor.rhai", r#" + fn on_mission_start(e,s,r) { + s.mission_actor = e.actor; + #{commands:[],state:s} + } + fn on_fire(e,s,r) { + s.player_actor = e.actor; + #{commands:[],state:s} + } + fn on_spawn(e,s,r) { + s.live = e.actor; + s.live_entities = e.entities; + #{commands:[#{op:"damage",entity:e.entity,amount:7}, + #{op:"move",entity:e.entity,x:90,y:60}],state:s} + } + fn on_tick(e,s,r) { + if !e.contains("entity") { return #{commands:[],state:s}; } + s.queued_health = e.health; + s.queued_x = e.x; + s.current = e.actor; + #{commands:[#{op:"damage",entity:e.entity,amount:3}],state:s} + } + fn on_hit(e,s,r) { + if !s.contains("dead") { + s.queued_hit_health = e.health; + s.dead = e.actor; + return #{commands:[#{op:"despawn",entity:e.entity}],state:s}; + } + s.removed = e.actor; + #{commands:[],state:s} + } + "#).unwrap(); + let pilot = Pilot::default(); + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + let entity = world.spawn(&ContentId::from("base:probe"), 20, 20, ®istry).unwrap(); + world.step(Input { fire: true, ..Input::default() }, ®istry, &mut runtime).unwrap(); + let state = &runtime.states["base/actor.rhai"]; + assert_eq!(state["live"], state["live_entities"][0]); + assert_eq!(state["live"]["entity"], entity); + assert_eq!(state["live"]["ground"], true); + assert_eq!(state["live"]["health"], 10); + assert_eq!(state["queued_health"], 10); + assert_eq!(state["current"]["health"], 3); + assert_eq!(state["current"]["x"], 90); + assert_ne!(state["queued_x"], state["current"]["x"]); + // Both hits were queued before the first hit callback. The first callback + // sees the current zero health, then despawns; the second sees null. + assert_eq!(state["queued_hit_health"], 3); + assert_eq!(state["dead"]["health"], 0); + assert!(state.get("removed").is_some()); + assert!(state.get("mission_actor").is_some()); + assert!(state.get("player_actor").is_some()); + assert_eq!(state["removed"], Value::Null); + assert_eq!(state["mission_actor"], Value::Null); + assert_eq!(state["player_actor"], Value::Null); + assert!(world.enemies.is_empty()); +} + +#[test] +fn natural_death_callback_has_no_retired_actor() { + let mut registry = base(); + registry.apply(pack(json!({"enemies":[{ + "id":"base:probe","name":"Probe","health":10,"speed":0, + "script":"retired.rhai" + }]})), "base").unwrap(); + let mut runtime = ScriptRuntime::new(); + runtime.add("base", "retired.rhai", r#"fn on_death(e,s,r) { + #{commands:[],state:#{actor:e.actor,entity:e.entity}} + }"#).unwrap(); + let pilot = Pilot::default(); + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + let entity = world.spawn(&ContentId::from("base:probe"), 20, 20, ®istry).unwrap(); + world.apply_commands(&[Command::Damage { entity, amount:10 }], ®istry).unwrap(); + world.step(Input::default(), ®istry, &mut runtime).unwrap(); + assert_eq!(runtime.states["base/retired.rhai"]["entity"], entity); + assert_eq!(runtime.states["base/retired.rhai"]["actor"], Value::Null); + assert!(world.enemies.is_empty()); +} + +#[test] +fn sixty_four_enemy_world_events_allow_documented_map_and_array_queries() { + for difficulty in 0..=3 { + let mut registry = base(); + registry.apply(pack(json!({"enemies":[{ + "id":"base:probe","name":"Probe","health":100,"speed":0, + "script":"dense-map.rhai" + }]})), "base").unwrap(); + let mut runtime = ScriptRuntime::new(); + runtime.add("base", "dense-map.rhai", r#"fn on_spawn(e,s,r) { + let n = if s.contains("n") { s.n + 1 } else { 1 }; + #{commands:[],state:#{n:n,has_actor:e.contains("actor"), + enemies:e.entities.len,difficulty:e.difficulty,health:e.actor.health}} + }"#).unwrap(); + let pilot = Pilot { difficulty, ..Pilot::default() }; + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + for i in 0..64 { + world.spawn(&ContentId::from("base:probe"), + 20 + i % 8 * 32, i / 8 * 10, ®istry).unwrap(); + } + world.step(Input::default(), ®istry, &mut runtime).unwrap(); + let state = &runtime.states["base/dense-map.rhai"]; + assert_eq!(world.tick, 1); + assert_eq!(state["n"], 64); + assert_eq!(state["has_actor"], true); + assert_eq!(state["enemies"], 64); + assert_eq!(state["difficulty"], difficulty); + assert_eq!(state["health"], 100); + } +} + +#[test] +fn recursive_map_limit_allows_bounded_work_and_rejects_larger_trees() { + // 12 doublings produce12,286 aggregate properties;13 produce24,574. + // The final state is tiny, so rejection exercises the intermediate map + // bound rather than the separate serialized callback-state limit. + let source = |rounds| format!(r#"fn on_tick(e,s,r) {{ + let m = #{{leaf:0}}; + for i in 0..{rounds} {{ m = #{{left:m,right:m}}; }} + #{{commands:[],state:#{{done:true}}}} + }}"#); + let mut runtime = ScriptRuntime::new(); + runtime.add("test", "nested.rhai", &source(12)).unwrap(); + runtime.begin_tick(); + runtime.invoke("test", "nested.rhai", "on_tick", &json!({}), 42).unwrap(); + assert_eq!(runtime.states["test/nested.rhai"]["done"], true); + let before = runtime.checkpoints(); + runtime.add("test", "nested.rhai", &source(13)).unwrap(); + runtime.begin_tick(); + let message = error(runtime.invoke("test", "nested.rhai", "on_tick", &json!({}), 42)); + assert!(message.contains("object map too large"), "{message}"); + assert_eq!(runtime.checkpoints(), before); +} + +#[test] +fn larger_event_maps_do_not_relax_the_64k_returned_state_limit() { + let mut runtime = ScriptRuntime::new(); + runtime.add("test", "state-cap.rhai", r#"fn on_tick(e,s,r) { + let values = []; + for i in 0..4000 { values.push(999999999999999999); } + #{commands:[],state:values} + }"#).unwrap(); + runtime.begin_tick(); + let message = error(runtime.invoke("test", "state-cap.rhai", "on_tick", &json!({}), 42)); + assert!(message.contains("state exceeds 64 KiB"), "{message}"); + assert!(runtime.states.is_empty()); + assert!(runtime.rng.is_empty()); +} + #[test] fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() { let mut registry = base();