Expose live mod actors and difficulty and support dense event queries
This commit is contained in:
1 parent
287b324e62
commit
93ea7064ba
10 files changed
+302
-11
No files matched your search
Binary file not shown.
Binary file not shown.
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+4
-1
@@ -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");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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::<Vec<_>>().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();
|
||||
|
||||
Reference in new issue
Block a user