From 18bb8f12422d299c9e4ddff5c557b8b07bb35c3b Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 29 Oct 2026 01:59:50 +0800 Subject: [PATCH] fix(sim): stop non-finite produced numbers with one checked path Val::Number now holds a Finite newtype, so a non-finite f64 cannot reach a store, a Change record, or the serialized trace, where it used to serialize as {"type":"number","value":null}. Every computed scalar goes through Finite::produced, or produced_number for a Val, and stops not_modeled naming the producing construct. That seals the remaining producers athena#132 left open: For and For Player Variable End increments, Set Max Health scaling, Damage and Heal applied amounts, Speed Of magnitude, match-clock advance and the timeline time, and the hero-settings conversions and setters. Fixes #133 --- crates/athena-sim/src/actions.rs | 38 ++-- crates/athena-sim/src/call.rs | 76 ++++---- crates/athena-sim/src/clock.rs | 34 ++-- crates/athena-sim/src/effects.rs | 2 +- crates/athena-sim/src/eval.rs | 12 +- crates/athena-sim/src/events.rs | 6 +- crates/athena-sim/src/finite.rs | 88 ++++++++++ crates/athena-sim/src/hud.rs | 2 +- crates/athena-sim/src/lib.rs | 26 ++- crates/athena-sim/src/modify.rs | 3 +- crates/athena-sim/src/player.rs | 31 ++-- crates/athena-sim/src/scenario.rs | 5 +- crates/athena-sim/src/sched.rs | 19 +- crates/athena-sim/src/state.rs | 61 +++---- crates/athena-sim/src/state_reads.rs | 5 +- crates/athena-sim/src/state_writes.rs | 37 ++-- crates/athena-sim/src/step.rs | 60 +++---- crates/athena-sim/src/timeline.rs | 4 +- crates/athena-sim/src/vector.rs | 29 ++-- crates/athena-sim/tests/all_heroes.rs | 4 +- .../athena-sim/tests/builtin_hero_values.rs | 2 +- crates/athena-sim/tests/control_flow.rs | 48 ++--- crates/athena-sim/tests/display.rs | 24 +-- crates/athena-sim/tests/display_entities.rs | 18 +- crates/athena-sim/tests/element_context.rs | 53 +++--- crates/athena-sim/tests/fixtures.rs | 5 +- crates/athena-sim/tests/hero_being_played.rs | 4 +- crates/athena-sim/tests/hero_settings.rs | 164 +++++++++++++++--- crates/athena-sim/tests/hud.rs | 8 +- crates/athena-sim/tests/loops.rs | 61 +++++-- crates/athena-sim/tests/match_clock.rs | 102 +++++++++-- crates/athena-sim/tests/noop_actions.rs | 4 +- crates/athena-sim/tests/number_of_players.rs | 8 +- crates/athena-sim/tests/player_events.rs | 34 ++-- crates/athena-sim/tests/player_state.rs | 30 +++- crates/athena-sim/tests/players_on_hero.rs | 4 +- crates/athena-sim/tests/random.rs | 11 +- crates/athena-sim/tests/scenario.rs | 45 ++--- crates/athena-sim/tests/server_load.rs | 4 +- crates/athena-sim/tests/slot.rs | 2 +- crates/athena-sim/tests/state_writes.rs | 80 +++++---- .../tests/status_buttons_effects.rs | 20 +-- crates/athena-sim/tests/strings.rs | 2 +- crates/athena-sim/tests/timeline.rs | 20 +-- crates/athena-sim/tests/values.rs | 123 +++++++++---- crates/athena-sim/tests/vectors.rs | 46 ++--- crates/athena-sim/tests/workshop_settings.rs | 8 +- docs/findings.md | 2 +- docs/simulator-interface.md | 4 +- docs/trace-format.md | 2 +- 50 files changed, 942 insertions(+), 538 deletions(-) create mode 100644 crates/athena-sim/src/finite.rs diff --git a/crates/athena-sim/src/actions.rs b/crates/athena-sim/src/actions.rs index 4cff43e..1da6bdd 100644 --- a/crates/athena-sim/src/actions.rs +++ b/crates/athena-sim/src/actions.rs @@ -16,7 +16,7 @@ use crate::state; use crate::state_writes::write_cell; use crate::step::{TICK_EVENT_LIMIT, World, dead, world_state}; use crate::unsupported; -use crate::{Change, PlayerWorld, Stop, Val}; +use crate::{Change, Finite, PlayerWorld, Stop, Val}; /// Execute a modeled status/button/rule action against `world`; `None` /// when `name` is not one of them. @@ -27,7 +27,7 @@ pub(crate) fn action( inst: &Instance<'_>, rule_idx: usize, subroutines: &BTreeMap, - now: f64, + now: Finite, ) -> Option> { if !matches!( name, @@ -55,7 +55,7 @@ fn run( inst: &Instance<'_>, rule_idx: usize, subroutines: &BTreeMap, - now: f64, + now: Finite, ) -> Result<(), Stop> { // The caller's `Env` cannot cross this call — it shares a borrow of // `world.players` — so the `Env` is built here. Each arm evaluates @@ -94,13 +94,14 @@ fn run( } let (applied, died) = { let target = world_state(world, &id)?; - let applied = amount * dealt * target.damage_received; + let applied = + Finite::produced(amount * dealt * target.damage_received, "action damage")?; let before = pools(&target.world); let health = target.world.health.current; - state::damage(&mut target.world, applied); + state::damage(&mut target.world, applied.get(), "action damage")?; // The hit kills when the pool had health and now has // none; the gate already ruled out a declared death. - let died = health > 0.0 && target.world.health.current == 0.0; + let died = health.get() > 0.0 && target.world.health.current.get() == 0.0; let after = pools(&target.world); for ((field, old), (_, new)) in before.into_iter().zip(after) { if old != new { @@ -114,7 +115,7 @@ fn run( } (applied, died) }; - if applied > 0.0 { + if applied.get() > 0.0 { // The events a modeled damage hit causes, in the // order they occur: the victim's event, the dealer's, // then the death the hit caused. `Event Damage`, @@ -158,9 +159,10 @@ fn run( continue; } let target = world_state(world, &id)?; - let applied = amount * dealt * target.healing_received; + let applied = + Finite::produced(amount * dealt * target.healing_received, "action heal")?; let old = target.world.health.current; - state::heal(&mut target.world, applied); + state::heal(&mut target.world, applied.get(), "action heal")?; let new = target.world.health.current; if new != old { world.changes.borrow_mut().push(Change::World { @@ -170,7 +172,7 @@ fn run( new: Val::Number(new), }); } - if applied > 0.0 { + if applied.get() > 0.0 { let values = event_values(&healer, &id, "eventHealing", applied); raise(world, "playerReceivedHealing", &id, values.clone())?; if let Val::Player(dealer_id) = &healer { @@ -188,7 +190,8 @@ fn run( // scales it is what P7 is for. let ids = player_ids(player, &env)?; let slot = button_slot(button)?; - let seconds = number(&eval(cooldown, &env)?)?; + let seconds = + Finite::produced(number(&eval(cooldown, &env)?)?, "action setAbilityCooldown")?; for id in ids { let target = world_state(world, &id)?; let old = target.world.cooldowns.get(slot).copied(); @@ -218,7 +221,14 @@ fn run( return unsupported(format!("{other:?} where a player is needed")); } } - (ids, status, Some(now + number(&eval(duration, &env)?)?)) + ( + ids, + status, + Some(Finite::produced( + now.get() + number(&eval(duration, &env)?)?, + "action setStatusEffect", + )?), + ) } ("clearStatusEffect", [player, status]) => { (player_ids(player, &env)?, status, None) @@ -334,7 +344,7 @@ fn run( /// The `(field, current)` pairs `Damage` reports for the pools that /// exist, in the order the pools absorb damage. -fn pools(world: &PlayerWorld) -> [(&'static str, Option); 3] { +fn pools(world: &PlayerWorld) -> [(&'static str, Option); 3] { [ ("shield", world.shield.as_ref().map(|pool| pool.current)), ("armor", world.armor.as_ref().map(|pool| pool.current)), @@ -371,7 +381,7 @@ fn raise( /// The event values a `Damage`/`Heal` raise binds: `Attacker` is the /// action's dealer (`Null` for the environment), `Victim` the target, /// and `key` — `eventDamage` or `eventHealing` — the applied amount. -fn event_values(cause: &Val, victim: &str, key: &str, amount: f64) -> BTreeMap { +fn event_values(cause: &Val, victim: &str, key: &str, amount: Finite) -> BTreeMap { BTreeMap::from([ ("attacker".to_string(), cause.clone()), ("victim".to_string(), Val::Player(victim.to_string())), diff --git a/crates/athena-sim/src/call.rs b/crates/athena-sim/src/call.rs index 6c2e672..549780b 100644 --- a/crates/athena-sim/src/call.rs +++ b/crates/athena-sim/src/call.rs @@ -9,7 +9,7 @@ use crate::ambiguous; use crate::eval::{Env, arguments, eval, number, values_equal}; use crate::scenario::{Phase, ServerLoad}; use crate::unsupported; -use crate::{Change, MissingInput, MissingInputKind, Stop, Val}; +use crate::{Change, Finite, MissingInput, MissingInputKind, Stop, Val}; fn missing( input: MissingInputKind, @@ -40,7 +40,7 @@ const EVENT_VALUES: &[&str] = &[ fn truthy(value: &Val) -> Result { match value { Val::Bool(b) => Ok(*b), - Val::Number(n) => Ok(*n != 0.0), + Val::Number(n) => Ok(n.get() != 0.0), Val::Null => Ok(false), other => unsupported(format!("condition value {other:?}")), } @@ -58,7 +58,7 @@ fn items(value: Val, construct: &str) -> Result, Stop> { fn render(value: &Val) -> Result { match value { - Val::Number(n) => Ok(format!("{n:.2}")), + Val::Number(n) => Ok(format!("{:.2}", n.get())), Val::Null => Ok("0".into()), Val::Bool(b) => Ok(b.to_string()), Val::Text(text) => Ok(text.clone()), @@ -165,7 +165,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { let args: &[Value] = &resolved; let binary = |op: fn(f64, f64) -> f64| -> Result { let [a, b] = arguments::<2>(name, args)?; - crate::vector::produced_number(op(number(&eval(a, env)?)?, number(&eval(b, env)?)?), name) + crate::produced_number(op(number(&eval(a, env)?)?, number(&eval(b, env)?)?), name) }; match name { // Evaluation reached a sentinel no operand position resolved — @@ -194,11 +194,14 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { current, average, peak, - }) => Ok(Val::Number(match name { - "getServerLoad" => current, - "getAverageServerLoad" => average, - _ => peak, - })), + }) => crate::produced_number( + match name { + "getServerLoad" => current, + "getAverageServerLoad" => average, + _ => peak, + }, + name, + ), None => missing( MissingInputKind::ServerLoad, None, @@ -286,7 +289,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { .hud .borrow() .last_text_id - .map_or(Val::Null, |id| Val::Number(id as f64))) + .map_or(Val::Null, |id| Val::Number(Finite::from(id)))) } // `Last Created Entity` is the id of the newest durable entity; // a `playEffect` has no entity id to hand out. @@ -296,7 +299,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { .effects .borrow() .last_created - .map_or(Val::Null, |id| Val::Number(id as f64))) + .map_or(Val::Null, |id| Val::Number(Finite::from(id)))) } // The catalog's `Player|EntityId` names "the specified player, // icon entity, or effect entity". A scenario player exists for @@ -306,7 +309,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { let [entity] = arguments::<1>(name, args)?; match eval(entity, env)? { Val::Player(_) => Ok(Val::Bool(true)), - Val::Number(id) => Ok(Val::Bool(env.effects.borrow().entity_exists(id))), + Val::Number(id) => Ok(Val::Bool(env.effects.borrow().entity_exists(id.get()))), Val::Null => Ok(Val::Bool(false)), other => unsupported(format!("{name} on {other:?}")), } @@ -315,11 +318,13 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { // one is no longer an instance. "getNumberOfEntityIds" => { let [] = arguments::<0>(name, args)?; - Ok(Val::Number(env.effects.borrow().entity_count() as f64)) + Ok(Val::Number(Finite::from( + env.effects.borrow().entity_count(), + ))) } "getNumberOfTextIds" => { let [] = arguments::<0>(name, args)?; - Ok(Val::Number(env.hud.borrow().text_count() as f64)) + Ok(Val::Number(Finite::from(env.hud.borrow().text_count()))) } "and" | "or" => { let [a, b] = arguments::<2>(name, args)?; @@ -379,11 +384,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { // non-finite argument has no sine to report. "sin" => { let [value] = arguments::<1>(name, args)?; - let sine = number(&eval(value, env)?)?.sin(); - if !sine.is_finite() { - return unsupported(format!("{name} produced a non-finite number")); - } - Ok(Val::Number(sine)) + crate::produced_number(number(&eval(value, env)?)?.sin(), name) } "roundToInteger" => { let [value, rounding] = arguments::<2>(name, args)?; @@ -391,7 +392,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { let Value::Enum { value: mode, .. } = rounding else { return unsupported(format!("{name} rounding {rounding:?}")); }; - crate::vector::produced_number( + crate::produced_number( match mode.as_str() { "UP" => n.ceil(), "DOWN" => n.floor(), @@ -441,7 +442,9 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { } "countOf" => { let [array] = arguments::<1>(name, args)?; - Ok(Val::Number(items(eval(array, env)?, name)?.len() as f64)) + Ok(Val::Number(Finite::from( + items(eval(array, env)?, name)?.len(), + ))) } "appendToArray" => { let [array, value] = arguments::<2>(name, args)?; @@ -490,7 +493,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { } Ok(match name { "arrayContains" => Val::Bool(found.is_some()), - _ => Val::Number(found.map_or(-1.0, |index| index as f64)), + _ => Val::Number(found.map_or(Finite::from(-1), Finite::from)), }) } "slice" => { @@ -525,14 +528,10 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { "randomInteger" | "randomReal" => { let [min, max] = arguments::<2>(name, args)?; let (min, max) = (number(&eval(min, env)?)?, number(&eval(max, env)?)?); - if !min.is_finite() || !max.is_finite() { - return unsupported(format!("{name} on a non-finite bound")); - } // A `Min` past `Max` swaps the bounds; unmeasured. let (min, max) = if min > max { (max, min) } else { (min, max) }; // Finite bounds still overflow the range: `Max - Min` is - // `inf` from `-1e308` to `1e308`, and a non-finite value - // serializes as `null` in the trace. + // `inf` from `-1e308` to `1e308`. if !(max - min).is_finite() { return unsupported(format!("{name} on a non-finite range")); } @@ -540,9 +539,9 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { let value = if name == "randomInteger" { // The bounds floor to integers; the range is inclusive. let (lo, hi) = (min.floor(), max.floor()); - Val::Number(lo + (u * (hi - lo + 1.0)).floor()) + crate::produced_number(lo + (u * (hi - lo + 1.0)).floor(), name)? } else { - Val::Number(min + (max - min) * u) + crate::produced_number(min + (max - min) * u, name)? }; Ok(drawn(env, name, value)) } @@ -577,7 +576,7 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { Some((element, index)) => Ok(if name == "currentArrayElement" { element.clone() } else { - Val::Number(index as f64) + Val::Number(Finite::from(index)) }), None => unsupported(format!("{name} outside an element context")), } @@ -611,16 +610,11 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { keyed.into_iter().map(|(_, item)| item.clone()).collect(), )) } - // `Mapped Array` collects the operand's per-element - // result; a produced number stays a value only while - // finite, as everywhere one is produced. + // `Mapped Array` collects the operand's per-element result. "mappedArray" => { let mut mapped = Vec::with_capacity(items.len()); for (index, item) in items.iter().enumerate() { - match eval(operand, &element_env(env, item, index))? { - Val::Number(n) => mapped.push(crate::vector::produced_number(n, name)?), - element => mapped.push(element), - } + mapped.push(eval(operand, &element_env(env, item, index))?); } Ok(Val::Array(mapped)) } @@ -765,11 +759,11 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { string(&eval(character, env)?)?, ); Ok(Val::Number(if character.is_empty() { - -1.0 + Finite::from(-1) } else { - source - .find(&character) - .map_or(-1.0, |byte| source[..byte].chars().count() as f64) + source.find(&character).map_or(Finite::from(-1), |byte| { + Finite::from(source[..byte].chars().count()) + }) })) } // `String Replace` replaces every occurrence of the pattern. An diff --git a/crates/athena-sim/src/clock.rs b/crates/athena-sim/src/clock.rs index 67c35bb..0747106 100644 --- a/crates/athena-sim/src/clock.rs +++ b/crates/athena-sim/src/clock.rs @@ -18,7 +18,7 @@ use crate::eval::{eval, number}; use crate::load::Instance; use crate::step::World; use crate::unsupported; -use crate::{Change, Stop, Val}; +use crate::{Change, Finite, Stop, Val}; /// The match clock: how far the run's game time has come, whether /// `Pause Match Time` holds it, the countdown `Match Time` reports @@ -31,13 +31,13 @@ pub(crate) struct Clock { /// The seconds `Match Time` reports; `None` until a `Set Match /// Time` writes it — the lobby's phase limit is not an input the /// scenario format carries, so the read has no start value. - pub match_time: Option, + pub match_time: Option, /// The `Set Slow Motion` percent; `100` is the client's normal /// rate. - pub slow_motion: f64, + pub slow_motion: Finite, /// The game-time seconds elapsed; `Total Time Elapsed` reads the /// start offset plus this. - pub elapsed: f64, + pub elapsed: Finite, } impl Clock { @@ -47,8 +47,8 @@ impl Clock { Self { paused: false, match_time: None, - slow_motion: 100.0, - elapsed: 0.0, + slow_motion: Finite::from(100), + elapsed: Finite::from(0), } } @@ -61,14 +61,14 @@ impl Clock { if self.paused { return Ok(()); } - let step = tick_s * self.slow_motion / 100.0; - self.elapsed += step; + let step = tick_s * self.slow_motion.get() / 100.0; + self.elapsed = Finite::produced(self.elapsed.get() + step, "match clock")?; if let Some(remaining) = self.match_time { - let remaining = remaining - step; + let remaining = remaining.get() - step; if remaining <= 0.0 { return unsupported("matchTime reaching zero"); } - self.match_time = Some(remaining); + self.match_time = Some(Finite::produced(remaining, "match clock")?); } Ok(()) } @@ -82,7 +82,7 @@ pub(crate) fn action( world: &mut World, inst: &Instance<'_>, rule_idx: usize, - now: f64, + now: Finite, ) -> Option> { if !matches!( name, @@ -99,7 +99,7 @@ fn run( world: &mut World, inst: &Instance<'_>, rule_idx: usize, - now: f64, + now: Finite, ) -> Result<(), Stop> { // The caller's `Env` cannot cross this call — it shares a borrow of // `world.players` — so the `Env` is built here, as in `actions.rs`. @@ -132,10 +132,7 @@ fn run( let [time] = args else { return unsupported(format!("{name} arguments")); }; - let time = number(&eval(time, &env)?)?; - if !time.is_finite() { - return unsupported(format!("{name} of a non-finite number")); - } + let time = Finite::produced(number(&eval(time, &env)?)?, "action setMatchTime")?; let old = world.clock.match_time; world.clock.match_time = Some(time); world.changes.borrow_mut().push(Change::MatchClock { @@ -149,10 +146,7 @@ fn run( let [percent] = args else { return unsupported(format!("{name} arguments")); }; - let percent = number(&eval(percent, &env)?)?; - if !percent.is_finite() { - return unsupported(format!("{name} of a non-finite number")); - } + let percent = Finite::produced(number(&eval(percent, &env)?)?, "action setSlowMotion")?; let old = world.clock.slow_motion; world.clock.slow_motion = percent; world.changes.borrow_mut().push(Change::MatchClock { diff --git a/crates/athena-sim/src/effects.rs b/crates/athena-sim/src/effects.rs index 53ded07..383ad7e 100644 --- a/crates/athena-sim/src/effects.rs +++ b/crates/athena-sim/src/effects.rs @@ -368,7 +368,7 @@ fn destroy( }; match eval(entity, env)? { Val::Null => {} - Val::Number(id) => destroy_kinds(effects, kinds, id, tick, changes), + Val::Number(id) => destroy_kinds(effects, kinds, id.get(), tick, changes), other => return unsupported(format!("{name} of {other:?}")), } Ok(()) diff --git a/crates/athena-sim/src/eval.rs b/crates/athena-sim/src/eval.rs index 5320714..2ebe385 100644 --- a/crates/athena-sim/src/eval.rs +++ b/crates/athena-sim/src/eval.rs @@ -9,7 +9,7 @@ use crate::hud::Hud; use crate::scenario::{EventCtx, Player, ServerLoad}; use crate::step::World; use crate::unsupported; -use crate::{Change, MissingInput, MissingInputKind, Stop, Val}; +use crate::{Change, Finite, MissingInput, MissingInputKind, Stop, Val}; pub(crate) struct Env<'a> { pub globals: &'a BTreeMap, @@ -66,10 +66,10 @@ pub(crate) struct Env<'a> { pub changes: &'a RefCell>, /// The `Total Time Elapsed` seconds: the start offset plus the /// match clock's elapsed game time. - pub now: f64, + pub now: Finite, /// The seconds `Match Time` reports; `None` until a `Set Match /// Time` writes the clock's countdown, so the read is `not_modeled`. - pub match_time: Option, + pub match_time: Option, } impl Env<'_> { @@ -90,7 +90,7 @@ impl World { each_player: Option<&'a str>, once: &'a RefCell>, rule: usize, - now: f64, + now: Finite, ) -> Env<'a> { Env { globals: &self.globals, @@ -204,7 +204,7 @@ pub(crate) fn button_slot(button: &Value) -> Result<&'static str, Stop> { pub(crate) fn number(value: &Val) -> Result { match value { - Val::Number(n) => Ok(*n), + Val::Number(n) => Ok(n.get()), Val::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }), Val::Null => Ok(0.0), other => unsupported(format!("arithmetic on {other:?}")), @@ -262,7 +262,7 @@ pub(crate) fn eval(value: &Value, env: &Env) -> Result { // The lexer accepts any digit run and `f64::from_str` saturates // an overflowing literal to infinity; a non-finite literal stops // here rather than reaching a store sink and serializing `null`. - Value::Number(n) => crate::vector::produced_number(*n, "number literal"), + Value::Number(n) => crate::produced_number(*n, "number literal"), Value::Bool(b) => Ok(Val::Bool(*b)), Value::Null => Ok(Val::Null), Value::String(text) => Ok(Val::Text(text.clone())), diff --git a/crates/athena-sim/src/events.rs b/crates/athena-sim/src/events.rs index b35116b..19e038e 100644 --- a/crates/athena-sim/src/events.rs +++ b/crates/athena-sim/src/events.rs @@ -16,7 +16,7 @@ use crate::load::{Frame, Instance, player_matches}; use crate::scenario::EventCtx; use crate::sched::{event_player, record_stop, run_and_record}; use crate::step::{World, conditions_hold}; -use crate::{MissingInput, MissingInputKind, RuleRun, RunEnd, Stop}; +use crate::{Finite, MissingInput, MissingInputKind, RuleRun, RunEnd, Stop}; /// Fire the events `world.pending_events` holds — raised by a modeled /// state change like `Damage`/`Heal` — in the order they occurred: each @@ -32,7 +32,7 @@ pub(crate) fn dispatch( subroutines: &BTreeMap, world: &mut World, tick: u64, - now: f64, + now: Finite, ) -> Result<(), Stop> { for ctx in std::mem::take(&mut world.pending_events) { for instance in instances.iter_mut() { @@ -80,7 +80,7 @@ pub(crate) fn fire( subroutines: &BTreeMap, world: &mut World, tick: u64, - now: f64, + now: Finite, ) -> Result<(), Stop> { // Each event's condition check is its own `Evaluate Once` pass. let pass = RefCell::new(BTreeMap::new()); diff --git a/crates/athena-sim/src/finite.rs b/crates/athena-sim/src/finite.rs new file mode 100644 index 0000000..898e8a6 --- /dev/null +++ b/crates/athena-sim/src/finite.rs @@ -0,0 +1,88 @@ +//! The finite-number boundary `Val::Number` and every numeric store +//! build on (athena#133). + +use std::fmt; + +use serde::Serialize; + +use crate::{NotModeled, Stop, Val}; + +/// A finite `f64`: the only payload a `Val::Number` accepts, so a +/// serialized number is never `null` (athena#133). A computed scalar +/// reaches it through [`Finite::produced`] — the one checked +/// construction — and a finite input that produces a non-finite +/// result stops the run `not_modeled`, naming the construct. Integer +/// counts and ids convert through `From`; [`Finite::new`] backs the +/// scenario-JSON boundary and test construction. +#[derive(Clone, Copy, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Finite(f64); + +impl Finite { + /// Wrap `value`, asserting it is finite. Test and boundary + /// construction only — a computed value belongs to + /// [`Finite::produced`]. + pub fn new(value: f64) -> Self { + assert!(value.is_finite(), "a non-finite number is not a value"); + Self(value) + } + + /// The checked construction a produced scalar takes: a finite + /// result wraps; a non-finite one is the run's `not_modeled`, + /// naming `construct`. + pub(crate) fn produced(value: f64, construct: &str) -> Result { + if value.is_finite() { + Ok(Self(value)) + } else { + Err(NotModeled::new(format!( + "{construct} produced a non-finite number" + ))) + } + } + + /// The `f64` inside. + pub fn get(self) -> f64 { + self.0 + } +} + +impl fmt::Debug for Finite { + /// The wrapped number, so a `Val` debugs like the value it holds. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +// Counts and ids fit in an `f64` exactly and are always finite. +impl From for Finite { + fn from(value: usize) -> Self { + Self(value as f64) + } +} + +impl From for Finite { + fn from(value: u64) -> Self { + Self(value as f64) + } +} + +impl From for Finite { + fn from(value: u8) -> Self { + Self(f64::from(value)) + } +} + +impl From for Finite { + fn from(value: i32) -> Self { + Self(f64::from(value)) + } +} + +/// The single checked construction a produced scalar's `Val` takes: +/// finite input that produces a non-finite `f64` stops the run +/// `not_modeled`, naming `construct` — such a value would serialize as +/// `{"type":"number","value":null}` while in-world arithmetic still +/// saw the real infinity (athena#133). +pub(crate) fn produced_number(value: f64, construct: &str) -> Result { + Ok(Val::Number(Finite::produced(value, construct)?)) +} diff --git a/crates/athena-sim/src/hud.rs b/crates/athena-sim/src/hud.rs index f62e256..ce56a76 100644 --- a/crates/athena-sim/src/hud.rs +++ b/crates/athena-sim/src/hud.rs @@ -429,7 +429,7 @@ fn destroy( // An unknown, `null` or already-destroyed text id destroys nothing. match eval(text_id, env)? { Val::Null => {} - Val::Number(id) => destroy_element(hud, kind, id, tick, changes), + Val::Number(id) => destroy_element(hud, kind, id.get(), tick, changes), other => return unsupported(format!("{name} of {other:?}")), } Ok(()) diff --git a/crates/athena-sim/src/lib.rs b/crates/athena-sim/src/lib.rs index 72832d0..36d607d 100644 --- a/crates/athena-sim/src/lib.rs +++ b/crates/athena-sim/src/lib.rs @@ -7,6 +7,7 @@ mod effects; mod eval; mod events; mod expect; +mod finite; mod geometry; mod hero_settings; mod hud; @@ -23,6 +24,8 @@ mod step; mod timeline; mod vector; +pub use finite::Finite; +pub(crate) use finite::produced_number; pub use timeline::{ Change, RuleRun, RunEnd, TIMELINE_CHANGE_LIMIT, TIMELINE_ENTRY_LIMIT, TIMELINE_RULE_LIMIT, Timeline, TimelineEntry, @@ -38,7 +41,7 @@ use serde::Serialize; pub enum Val { Null, Bool(bool), - Number(f64), + Number(Finite), Text(String), Array(Vec), /// A mocked player, identified by the scenario `id` that declared it. @@ -58,6 +61,15 @@ pub enum Val { }, } +impl Val { + /// A number `Val` for a value the caller asserts finite — test and + /// boundary construction. A computed value uses + /// [`Finite::produced`]. + pub fn number(value: f64) -> Self { + Self::Number(Finite::new(value)) + } +} + #[derive(Debug, Clone, PartialEq, Serialize)] pub struct LogEntry { pub tick: u64, @@ -244,15 +256,15 @@ pub struct ExpectationResult { /// One health pool: `current` points out of `max`. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct PoolState { - pub current: f64, - pub max: f64, + pub current: Finite, + pub max: Finite, } /// Ultimate charge: `charge` points toward `cost`. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct UltimateState { - pub charge: f64, - pub cost: f64, + pub charge: Finite, + pub cost: Finite, } /// A player's catalog-derived world state: health pools, ability cooldowns, @@ -270,12 +282,12 @@ pub struct PlayerWorld { pub armor: Option, /// Cooldown seconds per ability slot the catalog records a value for; /// keys are `slot` or `slot/variant`. - pub cooldowns: BTreeMap, + pub cooldowns: BTreeMap, /// Ultimate charge toward the catalog's cost. pub ultimate: UltimateState, /// Status effects `Set Status` applied: `Status` member id to the /// `Total Time Elapsed` seconds at which the status ends. - pub statuses: BTreeMap, + pub statuses: BTreeMap, /// The `Allow Button`/`Disallow Button` writes per button slot; a /// slot never written is allowed. pub buttons: BTreeMap, diff --git a/crates/athena-sim/src/modify.rs b/crates/athena-sim/src/modify.rs index db90901..c2b6e80 100644 --- a/crates/athena-sim/src/modify.rs +++ b/crates/athena-sim/src/modify.rs @@ -8,9 +8,10 @@ use std::collections::BTreeMap; use workshop_rs::{ModifyOp, Value}; use crate::eval::{number, values_equal}; +use crate::produced_number; use crate::scenario::Player; use crate::unsupported; -use crate::vector::{arithmetic, produced_number}; +use crate::vector::arithmetic; use crate::{Stop, Val}; /// The array a `Modify` array operation reads: an array as itself, `Null` diff --git a/crates/athena-sim/src/player.rs b/crates/athena-sim/src/player.rs index 9010fd3..4d802c6 100644 --- a/crates/athena-sim/src/player.rs +++ b/crates/athena-sim/src/player.rs @@ -13,7 +13,9 @@ use crate::eval::{Env, arguments, button_slot, eval, player_id}; use crate::scenario::{Player, Team}; use crate::state::WorldSlot; use crate::unsupported; -use crate::{MissingInput, MissingInputKind, NotModeled, PlayerWorld, PoolState, Stop, Val}; +use crate::{ + Finite, MissingInput, MissingInputKind, NotModeled, PlayerWorld, PoolState, Stop, Val, +}; fn missing( input: MissingInputKind, @@ -208,12 +210,12 @@ fn dispatch(name: &str, args: &[Value], env: &Env) -> Result { .filter(|player| player.team == Some(team)) .count() }; - Ok(Val::Number(match filter.as_str() { + Ok(Val::Number(Finite::from(match filter.as_str() { "ALL" => env.players.len(), "TEAM_1" => on_team(Team::Team1), "TEAM_2" => on_team(Team::Team2), other => return unsupported(format!("{name} on {other}")), - } as f64)) + }))) } // Modeled, not measured: the client's element order is // unmeasured, so the catalog's `Hero` member order is the @@ -313,7 +315,7 @@ fn dispatch(name: &str, args: &[Value], env: &Env) -> Result { let id = player_id(player, env)?; match env.players.get(&id) { Some(player) => match player.slot { - Some(slot) => Ok(Val::Number(f64::from(slot))), + Some(slot) => Ok(Val::Number(Finite::from(slot))), None => missing( MissingInputKind::Slot, Some(id), @@ -403,7 +405,7 @@ fn dispatch(name: &str, args: &[Value], env: &Env) -> Result { { return unsupported(format!("ability cooldown on variant-keyed slot {slot}")); } - Ok(Val::Number(0.0)) + Ok(Val::Number(Finite::from(0))) } // A live input: the value comes from the scenario player's // `state` map, keyed by [`crate::state_reads::STATE_READS`] — @@ -444,11 +446,13 @@ pub(crate) fn state_read_value( .get(read.key) .or_else(|| player.state.as_ref().and_then(|state| state.get(read.key))); if let Some(value) = stored { - return Ok(match (value, read.derived) { - (Val::Vector { x, y, z }, true) => Val::Number((x * x + y * y + z * z).sqrt()), - (value, false) => value.clone(), + return match (value, read.derived) { + (Val::Vector { x, y, z }, true) => { + crate::produced_number((x * x + y * y + z * z).sqrt(), construct) + } + (value, false) => Ok(value.clone()), _ => unreachable!("a derived read's key is a vector"), - }); + }; } // A declared `state` map opts the player into scenario-supplied // reads: a key it does not list — and no action wrote — is a @@ -475,7 +479,14 @@ pub(crate) fn state_read_value( } else { |pool: &PoolState| pool.max }; - Ok(Val::Number(pools.into_iter().flatten().map(pick).sum())) + crate::produced_number( + pools + .into_iter() + .flatten() + .map(|pool| pick(pool).get()) + .sum(), + construct, + ) } "isInSpawnRoom" => Ok(Val::Bool(false)), _ => missing( diff --git a/crates/athena-sim/src/scenario.rs b/crates/athena-sim/src/scenario.rs index 744ad74..4c6bf33 100644 --- a/crates/athena-sim/src/scenario.rs +++ b/crates/athena-sim/src/scenario.rs @@ -331,10 +331,11 @@ pub(crate) fn value_to_val(value: &serde_json::Value) -> Result Val::Null, serde_json::Value::Bool(b) => Val::Bool(*b), - serde_json::Value::Number(n) => Val::Number( + serde_json::Value::Number(n) => Val::Number(crate::Finite::new( n.as_f64() + .filter(|number| number.is_finite()) .ok_or_else(|| ScenarioError::Format(format!("number {n} is too large")))?, - ), + )), serde_json::Value::String(text) => Val::Text(text.clone()), serde_json::Value::Array(items) => { Val::Array(items.iter().map(value_to_val).collect::>()?) diff --git a/crates/athena-sim/src/sched.rs b/crates/athena-sim/src/sched.rs index 4f07797..38f1212 100644 --- a/crates/athena-sim/src/sched.rs +++ b/crates/athena-sim/src/sched.rs @@ -15,8 +15,8 @@ use crate::scenario::{EventCtx, Scenario}; use crate::state::{self, WorldSlot}; use crate::step::{World, conditions_hold, flag, run_instance}; use crate::{ - ExpectationResult, ExpectationStatus, NotModeled, Options, Outcome, PlayerState, RuleRun, - RunEnd, Stop, StopIndex, StopLocation, TIMELINE_CHANGE_LIMIT, TIMELINE_ENTRY_LIMIT, + ExpectationResult, ExpectationStatus, Finite, NotModeled, Options, Outcome, PlayerState, + RuleRun, RunEnd, Stop, StopIndex, StopLocation, TIMELINE_CHANGE_LIMIT, TIMELINE_ENTRY_LIMIT, TIMELINE_RULE_LIMIT, Timeline, TimelineEntry, Trace, Val, }; @@ -233,7 +233,7 @@ pub(crate) fn run_and_record( subroutines: &BTreeMap, world: &mut World, tick: u64, - now: f64, + now: Finite, ) -> Result<(), Stop> { // A flag left by an earlier `Abort` would mislabel the next run. inst.aborted = false; @@ -306,7 +306,7 @@ fn stale_wake( live: bool, tick: u64, ) -> Result<(), Stop> { - if live || world.clock.slow_motion == 100.0 { + if live || world.clock.slow_motion.get() == 100.0 { return Ok(()); } // A `Wait Until` frame names its action; a `Wait`'s suspending @@ -337,7 +337,7 @@ fn stale_wake( at, NotModeled::new(format!( "action {construct} under a {}% simulation rate", - world.clock.slow_motion + world.clock.slow_motion.get() )) .at(tick) .into(), @@ -363,7 +363,8 @@ fn tick_loop( if tick > 0 { world.clock.advance(TICK_S).map_err(|stop| stop.at(tick))?; } - let now = START_OFFSET_S + world.clock.elapsed; + let now = Finite::produced(START_OFFSET_S + world.clock.elapsed.get(), "match clock") + .map_err(|error| Stop::from(error).at(tick))?; let result = run_tick(&mut loaded, world, events, tick, now); // A tick that stopped the run still records what it did. let mut rules = std::mem::take(&mut world.runs); @@ -384,9 +385,11 @@ fn tick_loop( if world.timeline.entries.len() == TIMELINE_ENTRY_LIMIT { world.timeline.truncated = true; } else { + let time = Finite::produced((now.get() * 1000.0).round(), "match clock") + .map_err(|error| Stop::from(error).at(tick))?; world.timeline.entries.push(TimelineEntry { tick, - time: (now * 1000.0).round(), + time, rules, changes, }); @@ -406,7 +409,7 @@ fn run_tick( world: &mut World, by_tick: &BTreeMap>, tick: u64, - now: f64, + now: Finite, ) -> Result<(), Stop> { let Loaded { rules: rule_list, diff --git a/crates/athena-sim/src/state.rs b/crates/athena-sim/src/state.rs index 61af3da..3238609 100644 --- a/crates/athena-sim/src/state.rs +++ b/crates/athena-sim/src/state.rs @@ -23,7 +23,7 @@ use workshop_rs::settings::Settings; use crate::hero_settings; use crate::scenario::Team; -use crate::{NotModeled, PlayerWorld, PoolState, UltimateState, Val}; +use crate::{Finite, NotModeled, PlayerWorld, PoolState, Stop, UltimateState, Val}; /// The resolved world state of a scenario player. #[derive(Debug, Clone, PartialEq)] @@ -98,31 +98,23 @@ pub(crate) fn resolve( // Every hero has a `health` pool and an ultimate cost; `shield` and // `armor` exist only where the catalog records them (a hero without the // pool has no entry, never a zero). `health%` scales every pool. - let pool = |kind: HealthPool, field: &str| { - values - .health(&hero_id, kind) - .map(|max| PoolState { - current: max * mods.health, - max: max * mods.health, - }) - .ok_or_else(|| unmodeled(format!("hero `{hero}` has no `{field}` hero value"))) + let pool = |kind: HealthPool| -> Result, NotModeled> { + let Some(max) = values.health(&hero_id, kind) else { + return Ok(None); + }; + let max = Finite::produced(max * mods.health, "hero settings") + .map_err(|error| unmodeled(error.construct))?; + Ok(Some(PoolState { current: max, max })) }; - let health = pool(HealthPool::Health, "health")?; - let shield = values - .health(&hero_id, HealthPool::Shield) - .map(|max| PoolState { - current: max * mods.health, - max: max * mods.health, - }); - let armor = values - .health(&hero_id, HealthPool::Armor) - .map(|max| PoolState { - current: max * mods.health, - max: max * mods.health, - }); + let health = pool(HealthPool::Health)? + .ok_or_else(|| unmodeled(format!("hero `{hero}` has no `health` hero value")))?; + let shield = pool(HealthPool::Shield)?; + let armor = pool(HealthPool::Armor)?; let cost = values .ultimate_cost(&hero_id) .ok_or_else(|| unmodeled(format!("hero `{hero}` has no `ultimateCost` hero value")))?; + let cost = + Finite::produced(cost, "hero settings").map_err(|error| unmodeled(error.construct))?; // Cooldowns are reported for the ability slots the catalog records; // an ability without an entry has no cooldown and is simply absent. @@ -138,7 +130,9 @@ pub(crate) fn resolve( if let Some(seconds) = values.cooldown(&hero_id, slot, variant) { let scale = mods.cooldown_all * mods.cooldown_slots.get(slot).copied().unwrap_or(1.0); let key = variant.map_or_else(|| slot.to_string(), |v| format!("{slot}/{v}")); - cooldowns.insert(key, seconds * scale); + let seconds = Finite::produced(seconds * scale, "hero settings") + .map_err(|error| unmodeled(error.construct))?; + cooldowns.insert(key, seconds); } } @@ -148,7 +142,10 @@ pub(crate) fn resolve( shield, armor, cooldowns, - ultimate: UltimateState { charge: 0.0, cost }, + ultimate: UltimateState { + charge: Finite::from(0), + cost, + }, statuses: std::collections::BTreeMap::new(), buttons: std::collections::BTreeMap::new(), }, @@ -163,19 +160,23 @@ pub(crate) fn resolve( /// armor, health order; each pool absorbs up to its `current` and the rest /// spills into the next. Health floors at zero; a negative amount does /// nothing. -pub(crate) fn damage(world: &mut PlayerWorld, amount: f64) { +pub(crate) fn damage(world: &mut PlayerWorld, amount: f64, construct: &str) -> Result<(), Stop> { let mut rest = amount.max(0.0); for pool in [world.shield.as_mut(), world.armor.as_mut()] { let Some(pool) = pool else { continue }; - let taken = pool.current.min(rest); - pool.current -= taken; + let taken = pool.current.get().min(rest); + pool.current = Finite::produced(pool.current.get() - taken, construct)?; rest -= taken; } - world.health.current = (world.health.current - rest).max(0.0); + world.health.current = + Finite::produced((world.health.current.get() - rest).max(0.0), construct)?; + Ok(()) } /// `Heal(player, healer, amount)`: the `health` pool rises by the amount, /// capped at `max`; a negative amount does nothing. -pub(crate) fn heal(world: &mut PlayerWorld, amount: f64) { - world.health.current = (world.health.current + amount.max(0.0)).min(world.health.max); +pub(crate) fn heal(world: &mut PlayerWorld, amount: f64, construct: &str) -> Result<(), Stop> { + let healed = (world.health.current.get() + amount.max(0.0)).min(world.health.max.get()); + world.health.current = Finite::produced(healed, construct)?; + Ok(()) } diff --git a/crates/athena-sim/src/state_reads.rs b/crates/athena-sim/src/state_reads.rs index baa2faf..b334237 100644 --- a/crates/athena-sim/src/state_reads.rs +++ b/crates/athena-sim/src/state_reads.rs @@ -211,10 +211,11 @@ pub(crate) fn state_value( }; Ok(match (read.ty, value) { (StateType::Bool, serde_json::Value::Bool(b)) => Val::Bool(*b), - (StateType::Number, serde_json::Value::Number(n)) => Val::Number( + (StateType::Number, serde_json::Value::Number(n)) => Val::Number(crate::Finite::new( n.as_f64() + .filter(|number| number.is_finite()) .ok_or_else(|| ScenarioError::Format(format!("number {n} is too large")))?, - ), + )), (StateType::Vector, serde_json::Value::Object(object)) => match state_vector(object) { Some(vector) => vector.into(), None => return Err(mistyped()), diff --git a/crates/athena-sim/src/state_writes.rs b/crates/athena-sim/src/state_writes.rs index 3dc39af..f06adde 100644 --- a/crates/athena-sim/src/state_writes.rs +++ b/crates/athena-sim/src/state_writes.rs @@ -19,7 +19,7 @@ use crate::state_reads::state_key; use crate::step::{World, dead, world_state}; use crate::unsupported; use crate::vector::{Facing, Vec3}; -use crate::{Change, Stop, Val}; +use crate::{Change, Finite, Stop, Val}; /// The modifier key each stored-modifier action writes on the player. fn modifier_key(name: &str) -> &'static str { @@ -49,7 +49,7 @@ pub(crate) fn action( world: &mut World, inst: &Instance<'_>, rule_idx: usize, - now: f64, + now: Finite, ) -> Option> { if !matches!( name, @@ -86,7 +86,7 @@ fn run( world: &mut World, inst: &Instance<'_>, rule_idx: usize, - now: f64, + now: Finite, ) -> Result<(), Stop> { // The caller's `Env` cannot cross this call — it shares a borrow of // `world.players` — so the `Env` is built here, as in `actions.rs`. @@ -105,7 +105,7 @@ fn run( return unsupported("setHealth arguments"); }; let ids = player_ids(player, &env)?; - let amount = number(&eval(amount, &env)?)?; + let amount = crate::produced_number(number(&eval(amount, &env)?)?, "action setHealth")?; for id in ids { // The catalog: "only has an effect on living players" — // the gate reads `isDead` the way a program read would, @@ -114,13 +114,7 @@ fn run( if dead == Val::Bool(true) { continue; } - write_cell( - world, - &id, - "health", - Val::Number(amount), - "action setHealth", - )?; + write_cell(world, &id, "health", amount.clone(), "action setHealth")?; } } "setMaxHealth" => { @@ -133,8 +127,10 @@ fn run( // "a percentage of their max health": the current value // the same read would report — cell, declared `state`, // or the no-`state` pool fallback. - let max = number(&read_key(world, &id, "maxHealth", "action setMaxHealth")?)?; - let max = max * percent / 100.0; + let max = number(&read_key(world, &id, "maxHealth", "action setMaxHealth")?)? + * percent + / 100.0; + let new_max = crate::produced_number(max, "action setMaxHealth")?; // "will ensure that a player's current health will not // exceed the new max health". let health = number(&read_key(world, &id, "health", "action setMaxHealth")?)?; @@ -142,17 +138,11 @@ fn run( world, &id, "maxHealth", - Val::Number(max), + new_max.clone(), "action setMaxHealth", )?; if health > max { - write_cell( - world, - &id, - "health", - Val::Number(max), - "action setMaxHealth", - )?; + write_cell(world, &id, "health", new_max, "action setMaxHealth")?; } } } @@ -162,13 +152,14 @@ fn run( }; let ids = player_ids(player, &env)?; // `Number|Boolean`: `False` writes 0, `True` writes 1. - let charge = number(&eval(percent, &env)?)?; + let charge = + crate::produced_number(number(&eval(percent, &env)?)?, "action setUltCharge")?; for id in ids { write_cell( world, &id, "ultCharge", - Val::Number(charge), + charge.clone(), "action setUltCharge", )?; } diff --git a/crates/athena-sim/src/step.rs b/crates/athena-sim/src/step.rs index 4c37a55..534ad3e 100644 --- a/crates/athena-sim/src/step.rs +++ b/crates/athena-sim/src/step.rs @@ -18,7 +18,7 @@ use crate::modify::{apply_modify, modify_at, modify_op, set_at}; use crate::scenario::{EventCtx, Player, ServerLoad}; use crate::state::{self, WorldSlot}; use crate::unsupported; -use crate::{Change, LogEntry, RuleRun, Stop, StopIndex, StopLocation, Timeline, Val}; +use crate::{Change, Finite, LogEntry, RuleRun, Stop, StopIndex, StopLocation, Timeline, Val}; /// One server tick (P0, P1). const TICK_S: f64 = 0.016; @@ -136,7 +136,7 @@ pub(crate) fn dead(world: &mut World, id: &str) -> Result { // error never surfaces: every failed read falls back to the pool. match crate::player::state_read_value(&world.players, id, read, "a liveness check") { Ok(Val::Bool(dead)) => Ok(dead), - _ => Ok(world_state(world, id)?.world.health.current <= 0.0), + _ => Ok(world_state(world, id)?.world.health.current.get() <= 0.0), } } @@ -172,7 +172,7 @@ pub(crate) fn conditions_hold(rule: &Rule, env: &Env) -> Result { /// nothing. fn skip_count(value: &Val) -> Result { match value { - Val::Number(n) => Ok(n.max(0.0) as usize), + Val::Number(n) => Ok(n.get().max(0.0) as usize), Val::Bool(b) => Ok(*b as usize), Val::Null => Ok(0), other => unsupported(format!("skip count {other:?}")), @@ -191,7 +191,7 @@ pub(crate) fn run_instance( subroutines: &BTreeMap, world: &mut World, tick: u64, - now: f64, + now: Finite, ) -> Result<(), Stop> { for _ in 0..STEP_LIMIT { let Some(Frame { rule_idx, pc }) = inst.frames.last().copied() else { @@ -351,6 +351,7 @@ pub(crate) fn run_instance( number(&eval(stop, &env)?)?, number(&eval(step, &env)?)?, ); + let start = Finite::produced(start, "forGlobalVariable")?; let old = world.globals.get(variable).cloned().unwrap_or(Val::Null); world.globals.insert(variable.clone(), Val::Number(start)); world.changes.borrow_mut().push(Change::GlobalVariable { @@ -361,12 +362,12 @@ pub(crate) fn run_instance( }); // The loop-exit check runs at the `For` line once, then at // each `End`; a value already past the stop never enters. - inst.frames.last_mut().expect("a running frame").pc = if for_done(start, stop, step) - { - map.pairs[pc].expect("paired at load") + 1 - } else { - pc + 1 - }; + inst.frames.last_mut().expect("a running frame").pc = + if for_done(start.get(), stop, step) { + map.pairs[pc].expect("paired at load") + 1 + } else { + pc + 1 + }; } Action::ForPlayerVariable { player, @@ -384,6 +385,7 @@ pub(crate) fn run_instance( let Some(target) = world.players.get_mut(&id) else { return unsupported(format!("player variable of undeclared player `{id}`")); }; + let start = Finite::produced(start, "forPlayerVariable")?; let old = target.vars.get(variable).cloned().unwrap_or(Val::Null); target.vars.insert(variable.clone(), Val::Number(start)); world.changes.borrow_mut().push(Change::PlayerVariable { @@ -393,12 +395,12 @@ pub(crate) fn run_instance( old, new: Val::Number(start), }); - inst.frames.last_mut().expect("a running frame").pc = if for_done(start, stop, step) - { - map.pairs[pc].expect("paired at load") + 1 - } else { - pc + 1 - }; + inst.frames.last_mut().expect("a running frame").pc = + if for_done(start.get(), stop, step) { + map.pairs[pc].expect("paired at load") + 1 + } else { + pc + 1 + }; } Action::End => { let open = map.pairs[pc].expect("paired at load"); @@ -415,19 +417,16 @@ pub(crate) fn run_instance( let (stop, step) = (number(&eval(stop, &env)?)?, number(&eval(step, &env)?)?); let old = world.globals.get(variable).cloned().unwrap_or(Val::Null); - let updated = number(&old)? + step; + let updated = Finite::produced(number(&old)? + step, "forGlobalVariable")?; world.globals.insert(variable.clone(), Val::Number(updated)); + let done = for_done(updated.get(), stop, step); world.changes.borrow_mut().push(Change::GlobalVariable { name: variable.clone(), index: None, old, new: Val::Number(updated), }); - if for_done(updated, stop, step) { - pc + 1 - } else { - open + 1 - } + if done { pc + 1 } else { open + 1 } } Action::ForPlayerVariable { player, @@ -445,8 +444,9 @@ pub(crate) fn run_instance( )); }; let old = target.vars.get(variable).cloned().unwrap_or(Val::Null); - let updated = number(&old)? + step; + let updated = Finite::produced(number(&old)? + step, "forPlayerVariable")?; target.vars.insert(variable.clone(), Val::Number(updated)); + let done = for_done(updated.get(), stop, step); world.changes.borrow_mut().push(Change::PlayerVariable { player: id, name: variable.clone(), @@ -454,11 +454,7 @@ pub(crate) fn run_instance( old, new: Val::Number(updated), }); - if for_done(updated, stop, step) { - pc + 1 - } else { - open + 1 - } + if done { pc + 1 } else { open + 1 } } _ => pc + 1, }; @@ -601,10 +597,10 @@ pub(crate) fn run_instance( // The catalog's seconds-to-ticks mapping under a // non-100 `Set Slow Motion` factor is unmeasured — // stop rather than guess it (athena#120). - if world.clock.slow_motion != 100.0 { + if world.clock.slow_motion.get() != 100.0 { return unsupported(format!( "action wait under a {}% simulation rate", - world.clock.slow_motion + world.clock.slow_motion.get() )); } inst.wake = Some(tick + ticks); @@ -639,10 +635,10 @@ pub(crate) fn run_instance( } // The same unmeasured seconds-to-ticks mapping as // `wait` gates a suspending `Wait Until`. - if world.clock.slow_motion != 100.0 { + if world.clock.slow_motion.get() != 100.0 { return unsupported(format!( "action waitUntil under a {}% simulation rate", - world.clock.slow_motion + world.clock.slow_motion.get() )); } inst.wake = Some(tick + wait_ticks(timeout)); diff --git a/crates/athena-sim/src/timeline.rs b/crates/athena-sim/src/timeline.rs index ad76e4e..f890608 100644 --- a/crates/athena-sim/src/timeline.rs +++ b/crates/athena-sim/src/timeline.rs @@ -3,7 +3,7 @@ use serde::Serialize; -use crate::{EffectKind, HudKind, Val}; +use crate::{EffectKind, Finite, HudKind, Val}; /// One rule execution that started, resumed or ended on a tick. #[derive(Debug, Clone, PartialEq, Serialize)] @@ -143,7 +143,7 @@ pub struct TimelineEntry { /// The tick's `Total Time Elapsed` in milliseconds: 15 + the /// elapsed game-time seconds. The match clock drives it, so a /// paused or slowed clock repeats values across ticks. - pub time: f64, + pub time: Finite, pub rules: Vec, pub changes: Vec, } diff --git a/crates/athena-sim/src/vector.rs b/crates/athena-sim/src/vector.rs index 38d8cef..7423b66 100644 --- a/crates/athena-sim/src/vector.rs +++ b/crates/athena-sim/src/vector.rs @@ -15,6 +15,7 @@ use std::collections::BTreeMap; use workshop_rs::Value; use crate::eval::{Env, arguments, eval, number, player_id}; +use crate::produced_number; use crate::scenario::{Player, Team}; use crate::unsupported; use crate::{MissingInput, MissingInputKind, Stop, Val}; @@ -156,16 +157,6 @@ pub(crate) fn produced(v: Vec3, construct: &str) -> Result { } } -/// A produced number stays a value only while it is finite — the same -/// stop a non-finite vector component forces. -pub(crate) fn produced_number(n: f64, construct: &str) -> Result { - if n.is_finite() { - Ok(Val::Number(n)) - } else { - unsupported(format!("{construct} produced a non-finite number")) - } -} - /// The scenario player's `position`; a live input the run cannot guess. fn position(players: &BTreeMap, id: &str, construct: &str) -> Result { let Some(player) = players.get(id) else { @@ -349,11 +340,14 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { "__xComponentOf__" | "__yComponentOf__" | "__zComponentOf__" => { let [arg] = arguments::<1>(name, args)?; let v = vec(&eval(arg, env)?, name)?; - Ok(Val::Number(match name { - "__xComponentOf__" => v.x, - "__yComponentOf__" => v.y, - _ => v.z, - })) + produced_number( + match name { + "__xComponentOf__" => v.x, + "__yComponentOf__" => v.y, + _ => v.z, + }, + name, + ) } // `Vector Towards` is the raw displacement, `Direction Towards` // its unit direction — the same operand pair `Distance Between` @@ -499,9 +493,10 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { "getVerticalFacingAngle" => { let [player] = arguments::<1>(name, args)?; let id = player_id(player, env)?; - Ok(Val::Number( + produced_number( facing(env.players, &id, &format!("value {name}"))?.vertical, - )) + name, + ) } // `Players Within Radius`: the scenario players on the team // whose declared `position` is within `radius` of the evaluated diff --git a/crates/athena-sim/tests/all_heroes.rs b/crates/athena-sim/tests/all_heroes.rs index e4813df..da695a1 100644 --- a/crates/athena-sim/tests/all_heroes.rs +++ b/crates/athena-sim/tests/all_heroes.rs @@ -65,7 +65,7 @@ fn the_boundary_elements_are_the_catalog_ends() { )); assert_eq!(global(&trace), members.last().unwrap()); let trace = run(&set_global("Count Of(All Heroes())")); - assert_eq!(global(&trace), &Val::Number(members.len() as f64)); + assert_eq!(global(&trace), &Val::number(members.len() as f64)); } /// A member id compares equal to the `Hero` literal and to `Hero Of`'s @@ -80,7 +80,7 @@ fn the_elements_compare_equal_to_hero_literals_and_hero_of() { .position(|member| *member == Val::Text("ANA".to_string())) .unwrap() as f64; let trace = run(&set_global("Index Of Array Value(All Heroes(), Hero(Ana))")); - assert_eq!(global(&trace), &Val::Number(index)); + assert_eq!(global(&trace), &Val::number(index)); // `Hero Of` on a scenario player yields the member id `All Heroes` // lists, so the read holds. diff --git a/crates/athena-sim/tests/builtin_hero_values.rs b/crates/athena-sim/tests/builtin_hero_values.rs index 45d5673..ea91e45 100644 --- a/crates/athena-sim/tests/builtin_hero_values.rs +++ b/crates/athena-sim/tests/builtin_hero_values.rs @@ -37,5 +37,5 @@ fn the_builtin_hero_values_resolve_a_scenario_player() { .world .as_ref() .expect("p1 has world state"); - assert!(world.health.max > 0.0); + assert!(world.health.max.get() > 0.0); } diff --git a/crates/athena-sim/tests/control_flow.rs b/crates/athena-sim/tests/control_flow.rs index 2a667e3..d387522 100644 --- a/crates/athena-sim/tests/control_flow.rs +++ b/crates/athena-sim/tests/control_flow.rs @@ -45,8 +45,8 @@ rule ("t") { fn skip_steps_over_exactly_its_count() { let trace = run(SKIP); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); - assert_eq!(trace.globals["b"], Val::Number(7.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); + assert_eq!(trace.globals["b"], Val::number(7.0)); } /// `Skip(0)` skips nothing; `Skip` past the list ends the run. @@ -73,7 +73,7 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); assert_eq!(trace.globals["b"], Val::Null); assert_eq!(*ended(&trace), RunEnd::Finished); } @@ -104,9 +104,9 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); assert_eq!(trace.globals["b"], Val::Null); - assert_eq!(trace.globals["c"], Val::Number(3.0)); + assert_eq!(trace.globals["c"], Val::number(3.0)); } /// The client reads a `Skip If` count even when the condition is false, @@ -155,8 +155,8 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(99.0)); - assert_eq!(trace.globals["b"], Val::Number(2.0)); + assert_eq!(trace.globals["a"], Val::number(99.0)); + assert_eq!(trace.globals["b"], Val::number(2.0)); } /// `Abort` ends the action list and records `aborted`. @@ -182,7 +182,7 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); assert_eq!(trace.globals["b"], Val::Null); assert_eq!(*ended(&trace), RunEnd::Aborted); } @@ -210,7 +210,7 @@ rule ("t") { } "#; let trace = run(text); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); assert_eq!(trace.globals["b"], Val::Null); assert_eq!(*ended(&trace), RunEnd::Aborted); } @@ -252,7 +252,7 @@ rule ("Subroutine die") { } "#; let trace = run(text); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); assert_eq!(trace.globals["b"], Val::Null); assert_eq!(*ended(&trace), RunEnd::Aborted); } @@ -304,7 +304,7 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["i"], Val::Number(3.0)); + assert_eq!(trace.globals["i"], Val::number(3.0)); assert_eq!(*ended(&trace), RunEnd::Finished); } @@ -335,8 +335,8 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["i"], Val::Number(3.0)); - assert_eq!(trace.globals["passes"], Val::Number(3.0)); + assert_eq!(trace.globals["i"], Val::number(3.0)); + assert_eq!(trace.globals["passes"], Val::number(3.0)); } /// `Loop If Condition Is False` falls through while the conditions hold @@ -366,7 +366,7 @@ rule ("t") { let trace = run(holds); assert_eq!(trace.outcome, Outcome::Complete); // The conditions held, so no restart and the action after ran once. - assert_eq!(trace.globals["i"], Val::Number(11.0)); + assert_eq!(trace.globals["i"], Val::number(11.0)); let fails = r#" variables { @@ -425,8 +425,8 @@ rule ("t") { panic!("i must be a number"); }; assert!( - i > 10.0, - "the loop should have restarted many times, got {i}" + i.get() > 10.0, + "the loop should have restarted many times, got {i:?}" ); } @@ -470,10 +470,10 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["i"], Val::Number(3.0)); - assert_eq!(trace.globals["x"], Val::Number(2.0)); - assert_eq!(trace.globals["j"], Val::Number(5.0)); - assert_eq!(trace.globals["y"], Val::Number(3.0)); + assert_eq!(trace.globals["i"], Val::number(3.0)); + assert_eq!(trace.globals["x"], Val::number(2.0)); + assert_eq!(trace.globals["j"], Val::number(5.0)); + assert_eq!(trace.globals["y"], Val::number(3.0)); } /// `Continue` in a `For` lands on its `End`, which steps the variable — @@ -506,8 +506,8 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["i"], Val::Number(4.0)); - assert_eq!(trace.globals["x"], Val::Number(2.0)); + assert_eq!(trace.globals["i"], Val::number(4.0)); + assert_eq!(trace.globals["x"], Val::number(2.0)); } /// `Break` outside any loop is not modeled. @@ -565,6 +565,6 @@ rule ("t") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["i"], Val::Number(10.0)); - assert_eq!(trace.globals["a"], Val::Number(2.0)); + assert_eq!(trace.globals["i"], Val::number(10.0)); + assert_eq!(trace.globals["a"], Val::number(2.0)); } diff --git a/crates/athena-sim/tests/display.rs b/crates/athena-sim/tests/display.rs index 3c927fb..4cc3b08 100644 --- a/crates/athena-sim/tests/display.rs +++ b/crates/athena-sim/tests/display.rs @@ -74,10 +74,10 @@ fn each_producer_yields_an_opaque_display_value() { display( "customColor", vec![ - Val::Number(118.0), - Val::Number(93.0), - Val::Number(189.0), - Val::Number(255.0), + Val::number(118.0), + Val::number(93.0), + Val::number(189.0), + Val::number(255.0), ], ), ), @@ -153,10 +153,10 @@ fn a_display_value_flows_through_an_array() { display( "customColor", vec![ - Val::Number(1.0), - Val::Number(2.0), - Val::Number(3.0), - Val::Number(4.0) + Val::number(1.0), + Val::number(2.0), + Val::number(3.0), + Val::number(4.0) ] ) ); @@ -221,10 +221,10 @@ rule ("probe") { Some(display( "customColor", vec![ - Val::Number(118.0), - Val::Number(93.0), - Val::Number(189.0), - Val::Number(255.0), + Val::number(118.0), + Val::number(93.0), + Val::number(189.0), + Val::number(255.0), ], )) ); diff --git a/crates/athena-sim/tests/display_entities.rs b/crates/athena-sim/tests/display_entities.rs index c65e004..6eb26dc 100644 --- a/crates/athena-sim/tests/display_entities.rs +++ b/crates/athena-sim/tests/display_entities.rs @@ -70,7 +70,7 @@ fn creates_record_elements_and_hand_out_shared_ids() { z: 3.0 }) ); - assert_eq!(in_world.args.scale, Some(Val::Number(0.7))); + assert_eq!(in_world.args.scale, Some(Val::number(0.7))); assert_eq!( in_world.args.clipping.as_deref(), Some("CLIP_AGAINST_SURFACES") @@ -82,7 +82,7 @@ fn creates_record_elements_and_hand_out_shared_ids() { Some("VISIBLE_ALWAYS") ); assert_eq!(bar.kind, HudKind::ProgressBarInWorldText); - assert_eq!(bar.args.value, Some(Val::Number(25.0))); + assert_eq!(bar.args.value, Some(Val::number(25.0))); assert_eq!(bar.args.progress_bar_color, Some(Val::Text("BLUE".into()))); assert_eq!( bar.args.reevaluation.as_deref(), @@ -112,10 +112,10 @@ fn creates_record_elements_and_hand_out_shared_ids() { // `Last Text ID` is the newest text element across the four // creates — the in-world progress bar — and `Last Created Entity` // the newest entity, the icon. - assert_eq!(trace.globals["t"], Val::Number(2.0)); - assert_eq!(trace.globals["e"], Val::Number(2.0)); - assert_eq!(trace.globals["tc"], Val::Number(3.0)); - assert_eq!(trace.globals["ec"], Val::Number(3.0)); + assert_eq!(trace.globals["t"], Val::number(2.0)); + assert_eq!(trace.globals["e"], Val::number(2.0)); + assert_eq!(trace.globals["tc"], Val::number(3.0)); + assert_eq!(trace.globals["ec"], Val::number(3.0)); } /// A projectile effect is a durable entity `Last Created Entity` reads; @@ -146,8 +146,8 @@ rule ("init") { "#; let trace = run(text, &scenario(TWO_TEAMS)); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["e"], Val::Number(0.0)); - assert_eq!(trace.globals["ec"], Val::Number(0.0)); + assert_eq!(trace.globals["e"], Val::number(0.0)); + assert_eq!(trace.globals["ec"], Val::number(0.0)); assert_eq!(trace.globals["exists"], Val::Bool(true)); let [projectile] = trace.effects.as_slice() else { panic!("expected one entity, got {:?}", trace.effects); @@ -155,7 +155,7 @@ rule ("init") { assert_eq!(projectile.kind, EffectKind::ProjectileEffect); assert_eq!(projectile.args.effect.as_deref(), Some("ORB")); assert_eq!(projectile.args.friendly_to, Some(vec!["p1".to_string()])); - assert_eq!(projectile.args.oversize, Some(Val::Number(0.5))); + assert_eq!(projectile.args.oversize, Some(Val::number(0.5))); assert_eq!(projectile.destroyed_tick, None); } diff --git a/crates/athena-sim/tests/element_context.rs b/crates/athena-sim/tests/element_context.rs index dca8a52..5171f66 100644 --- a/crates/athena-sim/tests/element_context.rs +++ b/crates/athena-sim/tests/element_context.rs @@ -37,7 +37,7 @@ fn global(trace: &athena_sim::Trace) -> &Val { #[test] fn evaluate_once_evaluates_its_operand() { let trace = run(&set_global("Evaluate Once(First Of(Array(5, 6)))")); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); let trace = run(&set_global("Evaluate Once(Null)")); assert_eq!(global(&trace), &Val::Null); @@ -91,7 +91,7 @@ rule ("probe") { } } "#); - assert_eq!(global(&trace), &Val::Number(0.0)); + assert_eq!(global(&trace), &Val::number(0.0)); let trace = run(r#" variables { @@ -113,7 +113,7 @@ rule ("probe") { } } "#); - assert_eq!(global(&trace), &Val::Number(0.0)); + assert_eq!(global(&trace), &Val::number(0.0)); } /// The pin clears when the instance's next run starts, so a rule that @@ -152,7 +152,7 @@ rule ("bump") { } } "#); - assert_eq!(global(&trace), &Val::Number(2.0)); + assert_eq!(global(&trace), &Val::number(2.0)); } /// A rule-condition check is its own scope: the site re-reads every @@ -188,7 +188,7 @@ rule ("bump") { } } "#); - assert_eq!(global(&trace), &Val::Number(1.0)); + assert_eq!(global(&trace), &Val::number(1.0)); } /// A `Wait Until` call is one scope: its per-tick re-checks read the @@ -233,7 +233,7 @@ fn filtered_array_keeps_the_elements_the_condition_holds_for() { )); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(3.0), Val::Number(4.0)]) + &Val::Array(vec![Val::number(3.0), Val::number(4.0)]) ); // `Null` reads as the empty array. @@ -244,7 +244,7 @@ fn filtered_array_keeps_the_elements_the_condition_holds_for() { let trace = run(&set_global( "Filtered Array(Array(1, 2), Array Contains(Array(2, 3), Current Array Element))", )); - assert_eq!(global(&trace), &Val::Array(vec![Val::Number(2.0)])); + assert_eq!(global(&trace), &Val::Array(vec![Val::number(2.0)])); } #[test] @@ -255,9 +255,9 @@ fn sorted_array_orders_by_the_per_element_key() { assert_eq!( global(&trace), &Val::Array(vec![ - Val::Number(10.0), - Val::Number(20.0), - Val::Number(30.0) + Val::number(10.0), + Val::number(20.0), + Val::number(30.0) ]) ); @@ -269,9 +269,9 @@ fn sorted_array_orders_by_the_per_element_key() { assert_eq!( global(&trace), &Val::Array(vec![ - Val::Number(20.0), - Val::Number(10.0), - Val::Number(30.0) + Val::number(20.0), + Val::number(10.0), + Val::number(30.0) ]) ); @@ -319,9 +319,9 @@ fn mapped_array_collects_the_per_element_result() { assert_eq!( global(&trace), &Val::Array(vec![ - Val::Number(10.0), - Val::Number(21.0), - Val::Number(32.0) + Val::number(10.0), + Val::number(21.0), + Val::number(32.0) ]) ); @@ -344,7 +344,7 @@ fn mapped_array_collects_the_per_element_result() { let trace = run(&set_global("Mapped Array(Array(1, 2), -30)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(-30.0), Val::Number(-30.0)]) + &Val::Array(vec![Val::number(-30.0), Val::number(-30.0)]) ); // A nested element-context value rebinds the pair for its own @@ -355,22 +355,21 @@ fn mapped_array_collects_the_per_element_result() { assert_eq!( global(&trace), &Val::Array(vec![ - Val::Array(vec![Val::Number(1.0), Val::Number(1.0)]), - Val::Array(vec![Val::Number(2.0), Val::Number(1.0)]), + Val::Array(vec![Val::number(1.0), Val::number(1.0)]), + Val::Array(vec![Val::number(2.0), Val::number(1.0)]), ]) ); } -/// A mapped number is a produced number: one that is not finite — here -/// the variable a `For` `End` step left `inf` — stops `not_modeled` -/// instead of serializing `null` (athena#134). +/// A mapped number is a produced number: a non-finite result inside the +/// per-element operand stops `not_modeled` at the producing construct +/// instead of serializing `null` (athena#133). #[test] fn mapped_array_stops_on_a_non_finite_result() { let trace = run(r#" variables { global: - 0: g - 1: r + 0: r } rule ("probe") { @@ -378,16 +377,14 @@ rule ("probe") { Ongoing - Global; } actions { - For Global Variable(g, Raise To Power(10, 308), Multiply(Raise To Power(10, 308), 1.5), Raise To Power(10, 308)); - End; - Set Global Variable(r, Mapped Array(Array(1), Global.g)); + Set Global Variable(r, Mapped Array(Array(Raise To Power(10, 308)), Multiply(Current Array Element, Raise To Power(10, 308)))); } } "#); let Outcome::NotModeled(stop) = &trace.outcome else { panic!("{:?}", trace.outcome); }; - assert_eq!(stop.construct, "mappedArray produced a non-finite number"); + assert_eq!(stop.construct, "multiply produced a non-finite number"); } #[test] diff --git a/crates/athena-sim/tests/fixtures.rs b/crates/athena-sim/tests/fixtures.rs index a5eb023..cafd0d7 100644 --- a/crates/athena-sim/tests/fixtures.rs +++ b/crates/athena-sim/tests/fixtures.rs @@ -99,7 +99,8 @@ fn has_variable_block(text: &str) -> bool { } fn is_zero_like(value: &Val) -> bool { - matches!(value, Val::Null | Val::Bool(false)) || matches!(value, Val::Number(n) if *n == 0.0) + matches!(value, Val::Null | Val::Bool(false)) + || matches!(value, Val::Number(n) if n.get() == 0.0) } /// Variables in the export must agree; any variable the export omits must be @@ -116,7 +117,7 @@ fn compare_variables(trace: &Trace, captured: &Capture) -> Result<(), String> { ("假", other) => is_zero_like(other), (number, Val::Number(value)) => number .parse::() - .is_ok_and(|n| (n - value).abs() <= VARIABLE_TOLERANCE), + .is_ok_and(|n| (n - value.get()).abs() <= VARIABLE_TOLERANCE), _ => false, }; if !ok { diff --git a/crates/athena-sim/tests/hero_being_played.rs b/crates/athena-sim/tests/hero_being_played.rs index c0c9a8b..e55ce8b 100644 --- a/crates/athena-sim/tests/hero_being_played.rs +++ b/crates/athena-sim/tests/hero_being_played.rs @@ -182,8 +182,8 @@ rule ("event") { // The event fired mid-wait on tick 1; the wait re-checked the value // there and held it true, so it suspended to its timeout — tick 7, // `0.1` s rounded up — and the actions after it ran. - assert_eq!(trace.globals["ev"], Val::Number(1.0)); - assert_eq!(trace.globals["g"], Val::Number(1.0)); + assert_eq!(trace.globals["ev"], Val::number(1.0)); + assert_eq!(trace.globals["g"], Val::number(1.0)); let [started] = trace.timeline.entries[0].rules.as_slice() else { panic!("{:?}", trace.timeline.entries[0].rules); }; diff --git a/crates/athena-sim/tests/hero_settings.rs b/crates/athena-sim/tests/hero_settings.rs index adcd152..c2ee7d0 100644 --- a/crates/athena-sim/tests/hero_settings.rs +++ b/crates/athena-sim/tests/hero_settings.rs @@ -103,24 +103,30 @@ fn health_percent_scales_every_pool_for_all_teams() { assert_eq!(trace.outcome, Outcome::Complete); // ana: 200 health, 100 shield, 50 armor at 150%. let world = trace.players["p1"].world.as_ref().expect("p1 world"); - assert_eq!((world.health.current, world.health.max), (300.0, 300.0)); + assert_eq!( + (world.health.current.get(), world.health.max.get()), + (300.0, 300.0) + ); assert_eq!( ( - world.shield.as_ref().unwrap().current, - world.shield.as_ref().unwrap().max + world.shield.as_ref().unwrap().current.get(), + world.shield.as_ref().unwrap().max.get() ), (150.0, 150.0) ); assert_eq!( ( - world.armor.as_ref().unwrap().current, - world.armor.as_ref().unwrap().max + world.armor.as_ref().unwrap().current.get(), + world.armor.as_ref().unwrap().max.get() ), (75.0, 75.0) ); // ashe is on the other team but `allTeams` still applies. let world = trace.players["p2"].world.as_ref().expect("p2 world"); - assert_eq!((world.health.current, world.health.max), (300.0, 300.0)); + assert_eq!( + (world.health.current.get(), world.health.max.get()), + (300.0, 300.0) + ); } #[test] @@ -139,11 +145,11 @@ fn a_team_scoped_setting_applies_only_to_that_team() { let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) .unwrap_or_else(|error| panic!("{error}")); assert_eq!( - trace.players["p1"].world.as_ref().unwrap().health.max, + trace.players["p1"].world.as_ref().unwrap().health.max.get(), 300.0 ); assert_eq!( - trace.players["p2"].world.as_ref().unwrap().health.max, + trace.players["p2"].world.as_ref().unwrap().health.max.get(), 200.0 ); } @@ -172,7 +178,7 @@ fn a_hero_and_team_setting_requires_both() { ); let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) .unwrap_or_else(|error| panic!("{error}")); - let max = |id: &str| trace.players[id].world.as_ref().unwrap().health.max; + let max = |id: &str| trace.players[id].world.as_ref().unwrap().health.max.get(); // `Team 1 / Ana` applies to p1 only; `General / Ashe` applies to both // ashe players regardless of team. assert_eq!(max("p1"), 300.0); @@ -207,12 +213,15 @@ fn damage_dealt_and_received_scale_the_damage_amount() { assert_eq!(trace.outcome, Outcome::Complete); // p1 ana deals 100 at 200% to p2 ashe at 50% received: 100 lands. let p2 = trace.players["p2"].world.as_ref().unwrap(); - assert_eq!((p2.health.current, p2.health.max), (100.0, 200.0)); + assert_eq!( + (p2.health.current.get(), p2.health.max.get()), + (100.0, 200.0) + ); // p2 ashe deals 40 with no `damageDealt%` to p1 ana with no // `damageReceived%`: 40 lands on the shield pool. let p1 = trace.players["p1"].world.as_ref().unwrap(); - assert_eq!(p1.shield.as_ref().unwrap().current, 60.0); - assert_eq!(p1.health.current, 200.0); + assert_eq!(p1.shield.as_ref().unwrap().current.get(), 60.0); + assert_eq!(p1.health.current.get(), 200.0); } #[test] @@ -244,11 +253,11 @@ fn healing_dealt_and_received_scale_the_heal_amount() { // The 100 heal is dealt by ana at 150% and received by ashe at 50%: // 75 on top of the 50 left by the 150 damage. let p2 = trace.players["p2"].world.as_ref().unwrap(); - assert_eq!(p2.health.current, 125.0); + assert_eq!(p2.health.current.get(), 125.0); // ana took 300 through shield and armor down to 50 health; the 40 heal // dealt by ana lands at 150%: 110. let p1 = trace.players["p1"].world.as_ref().unwrap(); - assert_eq!(p1.health.current, 110.0); + assert_eq!(p1.health.current.get(), 110.0); } #[test] @@ -274,12 +283,12 @@ fn ability_cooldown_scales_stack_across_scopes() { let cooldowns = &trace.players["p1"].world.as_ref().unwrap().cooldowns; // p1 ana (team1): team `abilityCooldown%` halves every slot and the // hero `ability1Cooldown%` halves ability1 again. - assert_eq!(cooldowns["ability1"], 2.5); - assert_eq!(cooldowns["ability2"], 6.0); + assert_eq!(cooldowns["ability1"].get(), 2.5); + assert_eq!(cooldowns["ability2"].get(), 6.0); // p2 ana (team2): only the hero-scope scalar applies. let cooldowns = &trace.players["p2"].world.as_ref().unwrap().cooldowns; - assert_eq!(cooldowns["ability1"], 5.0); - assert_eq!(cooldowns["ability2"], 12.0); + assert_eq!(cooldowns["ability1"].get(), 5.0); + assert_eq!(cooldowns["ability2"].get(), 12.0); } #[test] @@ -354,7 +363,11 @@ fn an_unapplied_ult_setting_is_skipped_when_state_supplies_the_charge() { assert_eq!(trace.outcome, Outcome::Complete, "{setting}"); // The world resolved: `Damage` lands on ana's shield pool. let world = trace.players["p1"].world.as_ref().expect("p1 world"); - assert_eq!(world.shield.as_ref().unwrap().current, 90.0, "{setting}"); + assert_eq!( + world.shield.as_ref().unwrap().current.get(), + 90.0, + "{setting}" + ); } } @@ -389,7 +402,8 @@ fn an_unapplied_health_setting_needs_every_mapped_state_key() { .shield .as_ref() .unwrap() - .current, + .current + .get(), 90.0 ); @@ -506,6 +520,112 @@ fn an_applied_setting_still_scales_with_state_declared() { assert_eq!(trace.outcome, Outcome::Complete); // 150% pools; the 10 damage lands on the shield pool. let world = trace.players["p1"].world.as_ref().expect("p1 world"); - assert_eq!((world.health.current, world.health.max), (300.0, 300.0)); - assert_eq!(world.shield.as_ref().unwrap().current, 140.0); + assert_eq!( + (world.health.current.get(), world.health.max.get()), + (300.0, 300.0) + ); + assert_eq!(world.shield.as_ref().unwrap().current.get(), 140.0); +} + +/// A `health%` literal so large it saturates to `inf` makes the pool +/// scaling overflow: the player's world resolution fails `not_modeled`, +/// and touching it stops the run naming the conversion (athena#133). +#[test] +fn an_overflowing_health_percent_is_not_modeled_when_touched() { + let big = "9".repeat(320); + let text = hero_settings_program( + &format!(" General {{\n health%: {big}\n }}"), + " Damage(Event Player, Null, 10);", + ); + let scenario = + hero_settings_scenario(r#"{ "id": "p1", "hero": "ana", "team": "team1" }"#, "p1"); + let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) + .unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert!( + not_modeled + .construct + .contains("hero settings produced a non-finite number"), + "{}", + not_modeled.construct + ); +} + +/// Same for the cooldown setter: an `abilityCooldown%` literal that +/// saturates to `inf` overflows the `seconds * scale` write into the +/// player's `cooldowns` (athena#133). +#[test] +fn an_overflowing_cooldown_percent_is_not_modeled_when_touched() { + let big = "9".repeat(320); + let text = hero_settings_program( + &format!(" Team 1 {{\n abilityCooldown%: {big}\n }}"), + " Damage(Event Player, Null, 10);", + ); + let scenario = + hero_settings_scenario(r#"{ "id": "p1", "hero": "ana", "team": "team1" }"#, "p1"); + let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) + .unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert!( + not_modeled + .construct + .contains("hero settings produced a non-finite number"), + "{}", + not_modeled.construct + ); +} + +/// A finite `Damage` amount that overflows once the hero-setting scales +/// are applied stops `not_modeled` naming the action — the `applied` +/// amount bound into `Event Damage` is a produced number (athena#133). +#[test] +fn a_damage_amount_that_overflows_the_scales_is_not_modeled() { + let text = hero_settings_program( + r#" Team 1 { + Ana { + damageReceived%: 200 + } + }"#, + " Damage(Event Player, Null, Raise To Power(10, 308));", + ); + let scenario = + hero_settings_scenario(r#"{ "id": "p1", "hero": "ana", "team": "team1" }"#, "p1"); + let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) + .unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + not_modeled.construct, + "action damage produced a non-finite number" + ); +} + +/// Same for `Heal` through `healingReceived%` — the amount bound into +/// `Event Healing` is checked the same way (athena#133). +#[test] +fn a_heal_amount_that_overflows_the_scales_is_not_modeled() { + let text = hero_settings_program( + r#" Team 1 { + Ana { + healingReceived%: 200 + } + }"#, + " Heal(Event Player, Null, Raise To Power(10, 308));", + ); + let scenario = + hero_settings_scenario(r#"{ "id": "p1", "hero": "ana", "team": "team1" }"#, "p1"); + let trace = run_scenario_with_hero_values(&text, &scenario, &test_values()) + .unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + not_modeled.construct, + "action heal produced a non-finite number" + ); } diff --git a/crates/athena-sim/tests/hud.rs b/crates/athena-sim/tests/hud.rs index 02e9af6..594264d 100644 --- a/crates/athena-sim/tests/hud.rs +++ b/crates/athena-sim/tests/hud.rs @@ -47,7 +47,7 @@ fn a_hud_text_visible_to_one_team_lists_only_its_members() { assert_eq!(hud.args.header, Some(Val::Text("Score: 0".into()))); assert_eq!(hud.args.subheader, Some(Val::Null)); assert_eq!(hud.args.location.as_deref(), Some("LEFT")); - assert_eq!(hud.args.sort_order, Some(Val::Number(0.0))); + assert_eq!(hud.args.sort_order, Some(Val::number(0.0))); assert_eq!(hud.args.header_color, Some(Val::Text("WHITE".into()))); assert_eq!( hud.args.reevaluation.as_deref(), @@ -88,7 +88,7 @@ fn a_destroyed_element_keeps_its_entry_with_destroyed_tick() { panic!("expected two HUD elements, got {:?}", trace.hud); }; // `Last Text ID` named the newest element. - assert_eq!(trace.globals["t"], Val::Number(1.0)); + assert_eq!(trace.globals["t"], Val::number(1.0)); assert_eq!(second.id, 1); assert_eq!(second.destroyed_tick, Some(0)); // Wait(0.5) wakes on tick 32 (0.5 / 0.016 = 31.25, rounded up). @@ -122,10 +122,10 @@ fn a_progress_bar_records_its_arguments() { }; assert_eq!(bar.kind, HudKind::ProgressBarHud); assert_eq!(bar.recipients, ["p1"]); - assert_eq!(bar.args.value, Some(Val::Number(0.5))); + assert_eq!(bar.args.value, Some(Val::number(0.5))); assert_eq!(bar.args.text, Some(Val::Text("boss".into()))); assert_eq!(bar.args.location.as_deref(), Some("TOP")); - assert_eq!(bar.args.sort_order, Some(Val::Number(1.0))); + assert_eq!(bar.args.sort_order, Some(Val::number(1.0))); assert_eq!( bar.args.progress_bar_color, Some(Val::Text("YELLOW".into())) diff --git a/crates/athena-sim/tests/loops.rs b/crates/athena-sim/tests/loops.rs index e7ab311..c15339d 100644 --- a/crates/athena-sim/tests/loops.rs +++ b/crates/athena-sim/tests/loops.rs @@ -33,8 +33,8 @@ fn a_for_global_variable_containing_an_if_runs_to_the_expected_values() { let trace = run(FOR_IF); assert_eq!(trace.outcome, Outcome::Complete); // i runs 0..=4 (the exit check at `End` stops at 5); the `If` admits 3+4. - assert_eq!(trace.globals["i"], Val::Number(5.0)); - assert_eq!(trace.globals["total"], Val::Number(7.0)); + assert_eq!(trace.globals["i"], Val::number(5.0)); + assert_eq!(trace.globals["total"], Val::number(7.0)); } /// A `For` inside each branch of an `If`/`Else` chain. @@ -84,9 +84,9 @@ fn a_for_inside_an_if_pairs_its_end_with_the_for_not_the_if() { assert_eq!(trace.outcome, Outcome::Complete); // The taken branch's `For` iterates; the skipped branch's `For` never // writes `j`, and the `Else` runs instead. - assert_eq!(trace.globals["hits"], Val::Number(3.0)); - assert_eq!(trace.globals["i"], Val::Number(3.0)); - assert_eq!(trace.globals["misses"], Val::Number(99.0)); + assert_eq!(trace.globals["hits"], Val::number(3.0)); + assert_eq!(trace.globals["i"], Val::number(3.0)); + assert_eq!(trace.globals["misses"], Val::number(99.0)); assert_eq!(trace.globals["j"], Val::Null); } @@ -115,8 +115,8 @@ rule ("slow loop") { fn a_for_resumes_each_iteration_across_a_wait() { let trace = run(FOR_WAIT); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["hits"], Val::Number(3.0)); - assert_eq!(trace.globals["i"], Val::Number(3.0)); + assert_eq!(trace.globals["hits"], Val::number(3.0)); + assert_eq!(trace.globals["i"], Val::number(3.0)); } /// A descending `For` (negative step) and one whose start already passed the @@ -150,9 +150,9 @@ fn a_for_counts_down_and_skips_an_already_passed_range() { assert_eq!(trace.outcome, Outcome::Complete); // 5, 3, 1 run (i lands on -1); `never` is written 5 and the body is // skipped, so `down` counts only the first loop. - assert_eq!(trace.globals["down"], Val::Number(3.0)); - assert_eq!(trace.globals["i"], Val::Number(-1.0)); - assert_eq!(trace.globals["never"], Val::Number(5.0)); + assert_eq!(trace.globals["down"], Val::number(3.0)); + assert_eq!(trace.globals["i"], Val::number(-1.0)); + assert_eq!(trace.globals["never"], Val::number(5.0)); } /// `For Player Variable` on the `Event Player`. @@ -198,6 +198,39 @@ fn a_for_player_variable_counts_on_each_player() { ); } +/// A `For Player Variable` `End` step routes the written increment +/// through the finite check: `old + step` overflowing to `inf` stops +/// `not_modeled` instead of storing a value that serializes `null` +/// (athena#133). +#[test] +fn a_for_player_variable_end_stops_on_a_non_finite_increment() { + let text = r#" +variables { + player: + 0: i +} + +rule ("per player") { + event { + Ongoing - Each Player; + } + actions { + For Player Variable(Event Player, i, Raise To Power(10, 308), Multiply(Raise To Power(10, 308), 1.5), Raise To Power(10, 308)); + End; + } +} +"#; + let scenario = r#"{ "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1" }] }"#; + let trace = run_scenario(text, scenario).unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(stop) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + stop.construct, + "forPlayerVariable produced a non-finite number" + ); +} + /// Two `If`/`Else If` chains in a row: the first ends on a false clause. const CONSECUTIVE_CHAINS: &str = r#" variables { @@ -227,7 +260,7 @@ fn a_fresh_chain_does_not_inherit_a_previous_chains_clause_jump() { let trace = run(CONSECUTIVE_CHAINS); assert_eq!(trace.outcome, Outcome::Complete); // Only the true clause of the second chain runs. - assert_eq!(trace.globals["hits"], Val::Number(1.0)); + assert_eq!(trace.globals["hits"], Val::number(1.0)); } /// An `If`/`End` nested in a matched clause body exhausts its chain on a @@ -275,7 +308,7 @@ rule ("nested else-if") { fn an_exhausted_if_inside_a_clause_does_not_trigger_the_outer_else() { let trace = run(NESTED_EXHAUSTED_IF); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["hits"], Val::Number(2.0)); + assert_eq!(trace.globals["hits"], Val::number(2.0)); } /// The nested `If` must not deliver the run to the `Else If` head either: a @@ -307,7 +340,7 @@ rule ("nested else-if") { fn an_exhausted_if_inside_a_clause_does_not_evaluate_the_outer_else_if() { let trace = run(NESTED_EXHAUSTED_IF_UNEVALUATED); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["hits"], Val::Number(1.0)); + assert_eq!(trace.globals["hits"], Val::number(1.0)); } /// A subroutine ending in an exhausted `If`/`End` returns with a pending @@ -353,7 +386,7 @@ rule ("caller") { fn an_if_exhausted_inside_a_subroutine_does_not_trigger_the_callers_else() { let trace = run(SUBROUTINE_EXHAUSTED_IF); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["hits"], Val::Number(1.0)); + assert_eq!(trace.globals["hits"], Val::number(1.0)); } /// A stray `End` line: the parser keeps it as a raw action, and reaching it diff --git a/crates/athena-sim/tests/match_clock.rs b/crates/athena-sim/tests/match_clock.rs index 87c1a6f..275270e 100644 --- a/crates/athena-sim/tests/match_clock.rs +++ b/crates/athena-sim/tests/match_clock.rs @@ -64,13 +64,13 @@ fn pause_freezes_total_time_elapsed_and_records_the_write() { assert_eq!(trace.outcome, Outcome::Complete); // The clock stands while paused: both reads see the run's start, // and the wake tick's `time` repeats the first tick's. - assert_eq!(trace.globals["t0"], Val::Number(0.015)); - assert_eq!(trace.globals["t1"], Val::Number(0.015)); + assert_eq!(trace.globals["t0"], Val::number(0.015)); + assert_eq!(trace.globals["t1"], Val::number(0.015)); let [start, wake] = trace.timeline.entries.as_slice() else { panic!("expected two entries, got {:?}", trace.timeline.entries); }; - assert_eq!((start.tick, start.time), (0, 15.0)); - assert_eq!((wake.tick, wake.time), (3, 15.0)); + assert_eq!((start.tick, start.time.get()), (0, 15.0)); + assert_eq!((wake.tick, wake.time.get()), (3, 15.0)); assert_eq!( clock_changes(&trace, 0), [Change::MatchClock { @@ -94,8 +94,8 @@ fn unpause_resumes_the_clock() { 1.0, ); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["t0"], Val::Number(0.015)); - assert_eq!(trace.globals["t1"], Val::Number(0.031)); + assert_eq!(trace.globals["t0"], Val::number(0.015)); + assert_eq!(trace.globals["t1"], Val::number(0.031)); assert_eq!( clock_changes(&trace, 1), [Change::MatchClock { @@ -117,16 +117,16 @@ fn set_match_time_writes_the_countdown_match_time_reads() { 1.0, ); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["m"], Val::Number(60.0)); + assert_eq!(trace.globals["m"], Val::number(60.0)); // One unpaused tick counted the countdown down by 16 ms. - assert_eq!(trace.globals["t1"], Val::Number(59.984)); + assert_eq!(trace.globals["t1"], Val::number(59.984)); assert_eq!( clock_changes(&trace, 0), [Change::MatchClock { action: "setMatchTime".into(), field: "matchTime".into(), old: Val::Null, - new: Val::Number(60.0), + new: Val::number(60.0), }] ); } @@ -143,7 +143,7 @@ fn a_paused_countdown_holds_its_value() { 1.0, ); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["m"], Val::Number(0.0)); + assert_eq!(trace.globals["m"], Val::number(0.0)); } #[test] @@ -206,20 +206,20 @@ rule ("slow") { "#; let trace = run_text(text, athena_sim::Options { seconds: 1.0 }).unwrap(); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["hit"], Val::Number(0.023)); + assert_eq!(trace.globals["hit"], Val::number(0.023)); let [start, wake] = trace.timeline.entries.as_slice() else { panic!("expected two entries, got {:?}", trace.timeline.entries); }; - assert_eq!((start.tick, start.time), (0, 15.0)); - assert_eq!((wake.tick, wake.time), (1, 23.0)); + assert_eq!((start.tick, start.time.get()), (0, 15.0)); + assert_eq!((wake.tick, wake.time.get()), (1, 23.0)); assert_eq!(wake.rules[0].ended, RunEnd::Finished); assert_eq!( clock_changes(&trace, 0), [Change::MatchClock { action: "setSlowMotion".into(), field: "slowMotion".into(), - old: Val::Number(100.0), - new: Val::Number(50.0), + old: Val::number(100.0), + new: Val::number(50.0), }] ); } @@ -272,7 +272,7 @@ fn a_true_wait_until_under_a_non_100_factor_still_passes() { 1.0, ); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["g"], Val::Number(1.0)); + assert_eq!(trace.globals["g"], Val::number(1.0)); } #[test] @@ -417,7 +417,7 @@ rule ("setter") { }"#; let trace = run_scenario(text, scenario).unwrap_or_else(|error| panic!("{error}")); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["h"], Val::Number(7.0)); + assert_eq!(trace.globals["h"], Val::number(7.0)); } #[test] @@ -485,7 +485,7 @@ rule ("setter") { }"#; let trace = run_scenario(text, scenario).unwrap_or_else(|error| panic!("{error}")); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["h"], Val::Number(9.0)); + assert_eq!(trace.globals["h"], Val::number(9.0)); } #[test] @@ -612,3 +612,69 @@ fn a_non_finite_clock_argument_is_not_modeled() { ); } } + +/// A finite `Set Slow Motion` factor so large that a few thousand ticks +/// of `tick_s * factor` overflow `elapsed`: the next `advance` stops +/// `not_modeled` instead of writing `inf` into `Total Time Elapsed` +/// (athena#133). The ticks in between are quiet — the run stops on the +/// clock itself, not on a rule. +#[test] +fn a_slow_motion_factor_that_overflows_the_elapsed_clock_is_not_modeled() { + let trace = run( + " Set Slow Motion(Multiply(Raise To Power(10, 308), 1.5)); + Set Global Variable(g, 1);", + 121.0, + ); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + not_modeled.construct, + "match clock produced a non-finite number" + ); +} + +/// The timeline's `time` field is `now * 1000`: an elapsed clock that +/// still fits in seconds can overflow in milliseconds. A player event +/// landing on a tick whose timeline `time` is `inf` stops `not_modeled` +/// (athena#133). +#[test] +fn a_timeline_time_that_overflows_is_not_modeled() { + let text = r#" +variables { + global: + 0: g +} + +rule ("clock") { + event { + Ongoing - Global; + } + actions { + Set Slow Motion(Multiply(Raise To Power(10, 308), 1.5)); + } +} + +rule ("join") { + event { + Player Joined Match; + All; + All; + } + actions { + Set Global Variable(g, 1); + } +} +"#; + let scenario = r#"{ "scenarioVersion": 1, "seconds": 1, + "players": [{ "id": "p1", "host": true, "hero": "ana" }], + "events": [{ "kind": "playerJoined", "time": 0.2, "eventPlayer": "p1" }] }"#; + let trace = run_scenario(text, scenario).unwrap_or_else(|error| panic!("{error}")); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + not_modeled.construct, + "match clock produced a non-finite number" + ); +} diff --git a/crates/athena-sim/tests/noop_actions.rs b/crates/athena-sim/tests/noop_actions.rs index 8260665..716a51c 100644 --- a/crates/athena-sim/tests/noop_actions.rs +++ b/crates/athena-sim/tests/noop_actions.rs @@ -84,8 +84,8 @@ rule ("t") { "#; let trace = run(text, ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["before"], Val::Number(1.0)); - assert_eq!(trace.globals["after"], Val::Number(2.0)); + assert_eq!(trace.globals["before"], Val::number(1.0)); + assert_eq!(trace.globals["after"], Val::number(2.0)); // The run is in the trace, finished; its only changes are the two // variable writes — the no-read actions recorded nothing. let entry = &trace.timeline.entries[0]; diff --git a/crates/athena-sim/tests/number_of_players.rs b/crates/athena-sim/tests/number_of_players.rs index 6fb3853..97f1d53 100644 --- a/crates/athena-sim/tests/number_of_players.rs +++ b/crates/athena-sim/tests/number_of_players.rs @@ -44,7 +44,7 @@ fn the_count_of_players_on_the_team() { for (team, count) in [("Team 1", 2.0), ("Team 2", 1.0), ("All Teams", 3.0)] { let trace = run(&set_global(&format!("Number Of Players({team})")), ROSTER); assert_eq!(trace.outcome, Outcome::Complete, "{team}"); - assert_eq!(trace.globals["g"], Val::Number(count), "{team}"); + assert_eq!(trace.globals["g"], Val::number(count), "{team}"); } } @@ -67,7 +67,7 @@ fn an_empty_team_or_roster_counts_zero() { ] { let trace = run(&set_global(value), scenario); assert_eq!(trace.outcome, Outcome::Complete, "{value}"); - assert_eq!(trace.globals["g"], Val::Number(0.0), "{value}"); + assert_eq!(trace.globals["g"], Val::number(0.0), "{value}"); let trace = run( &set_global(&format!("Compare({value}, ==, Null)")), @@ -88,7 +88,7 @@ fn a_player_without_a_team_counts_only_for_all_teams() { ] }"#; for (team, count) in [("Team 1", 1.0), ("Team 2", 0.0), ("All Teams", 2.0)] { let trace = run(&set_global(&format!("Number Of Players({team})")), scenario); - assert_eq!(trace.globals["g"], Val::Number(count), "{team}"); + assert_eq!(trace.globals["g"], Val::number(count), "{team}"); } } @@ -100,7 +100,7 @@ fn an_evaluated_member_id_names_the_team() { &set_global("Number Of Players(Team Of(Host Player))"), ROSTER, ); - assert_eq!(trace.globals["g"], Val::Number(2.0)); + assert_eq!(trace.globals["g"], Val::number(2.0)); } /// An argument that is not a catalog `Team` member after evaluation is diff --git a/crates/athena-sim/tests/player_events.rs b/crates/athena-sim/tests/player_events.rs index 1d0962a..b8c94c4 100644 --- a/crates/athena-sim/tests/player_events.rs +++ b/crates/athena-sim/tests/player_events.rs @@ -62,7 +62,7 @@ fn an_injected_player_event_runs_a_matching_rule_once() { }"#; let trace = run(LEFT_RULE, scenario); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["left"], Val::Number(1.0)); + assert_eq!(trace.globals["left"], Val::number(1.0)); assert_eq!(trace.globals["who"], Val::Text("REAPER".into())); let [run] = trace.timeline.entries[0].rules.as_slice() else { panic!( @@ -163,13 +163,13 @@ const DUEL: &str = r#"{ fn a_modeled_damage_hit_raises_the_damage_events_in_occurrence_order() { let trace = run(DAMAGE_EVENTS, DUEL); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["took"], Val::Number(2.0)); - assert_eq!(trace.globals["dealt"], Val::Number(2.0)); - assert_eq!(trace.globals["died"], Val::Number(1.0)); - assert_eq!(trace.globals["blow"], Val::Number(1.0)); + assert_eq!(trace.globals["took"], Val::number(2.0)); + assert_eq!(trace.globals["dealt"], Val::number(2.0)); + assert_eq!(trace.globals["died"], Val::number(1.0)); + assert_eq!(trace.globals["blow"], Val::number(1.0)); // `Event Damage` is the applied hit; `Attacker`/`Victim` the dealer // and the target. - assert_eq!(trace.globals["dmg"], Val::Number(9999.0)); + assert_eq!(trace.globals["dmg"], Val::number(9999.0)); assert_eq!(trace.globals["atk"], Val::Text("ANA".into())); assert_eq!(trace.globals["vic"], Val::Text("REAPER".into())); assert_eq!( @@ -241,7 +241,7 @@ rule ("dealt") { "#; let trace = run(text, DUEL); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["took"], Val::Number(1.0)); + assert_eq!(trace.globals["took"], Val::number(1.0)); // The dealt rule never ran: the variable stays `null`. assert_eq!(trace.globals["dealt"], Val::Null); assert_eq!(trace.globals["atk"], Val::Null); @@ -294,9 +294,9 @@ rule ("dealt") { "#; let trace = run(text, DUEL); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["received"], Val::Number(1.0)); - assert_eq!(trace.globals["dealt"], Val::Number(1.0)); - assert_eq!(trace.globals["healed"], Val::Number(20.0)); + assert_eq!(trace.globals["received"], Val::number(1.0)); + assert_eq!(trace.globals["dealt"], Val::number(1.0)); + assert_eq!(trace.globals["healed"], Val::number(20.0)); assert_eq!(run_names(&trace, 0), ["driver", "received", "dealt"]); let dealt_run = &trace.timeline.entries[0].rules[2]; assert_eq!(dealt_run.event_player.as_deref(), Some("p1")); @@ -370,8 +370,8 @@ rule ("bastion") { "#; let trace = run(text, DUEL); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["team2"], Val::Number(1.0)); - assert_eq!(trace.globals["reaper"], Val::Number(1.0)); + assert_eq!(trace.globals["team2"], Val::number(1.0)); + assert_eq!(trace.globals["reaper"], Val::number(1.0)); // The non-matching rules never ran: the variables stay `null`. assert_eq!(trace.globals["team1"], Val::Null); assert_eq!(trace.globals["bastion"], Val::Null); @@ -502,7 +502,7 @@ rule ("died") { assert_eq!(trace.outcome, Outcome::Complete); // Without the revive's health-pool refill the second hit could not // reach zero, so `Player Died` would fire once. - assert_eq!(trace.globals["died"], Val::Number(2.0)); + assert_eq!(trace.globals["died"], Val::number(2.0)); assert_eq!(trace.players["p2"].state["isDead"], Val::Bool(true)); } @@ -539,7 +539,7 @@ rule ("died") { "#; let trace = run(text, DUEL); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["died"], Val::Number(2.0)); + assert_eq!(trace.globals["died"], Val::number(2.0)); } /// The catalog splits the revives: `Resurrect` acts on dead players, so @@ -561,7 +561,7 @@ rule ("driver") { let trace = run(text, DUEL); assert_eq!(trace.outcome, Outcome::Complete); let world = trace.players["p2"].world.as_ref().expect("world state"); - assert_eq!(world.health.current, world.health.max - 50.0); + assert_eq!(world.health.current.get(), world.health.max.get() - 50.0); let text = r#" rule ("driver") { @@ -619,7 +619,7 @@ rule ("events") { assert_eq!(trace.outcome, Outcome::Complete); assert_eq!(trace.globals["dead"], Val::Bool(true)); // Only the killing hit's `Player Took Damage` ran. - assert_eq!(trace.globals["events"], Val::Number(1.0)); + assert_eq!(trace.globals["events"], Val::number(1.0)); } /// An instance mid-`Wait` queues a raised event like an injected one @@ -658,7 +658,7 @@ rule ("took") { assert_eq!(trace.outcome, Outcome::Complete); // The second event queued behind the suspended run and fired when // the `Wait` resumed and ended it. - assert_eq!(trace.globals["took"], Val::Number(2.0)); + assert_eq!(trace.globals["took"], Val::number(2.0)); } /// The per-tick bound on raised events keeps a `Damage` fan-out — a diff --git a/crates/athena-sim/tests/player_state.rs b/crates/athena-sim/tests/player_state.rs index a9427ea..83f04b0 100644 --- a/crates/athena-sim/tests/player_state.rs +++ b/crates/athena-sim/tests/player_state.rs @@ -201,7 +201,7 @@ fn the_table_drives_every_listed_read() { z: 0.0, }, ("boolean", _) => Val::Bool(value.parse::().unwrap()), - _ => Val::Number(value.parse::().unwrap()), + _ => Val::number(value.parse::().unwrap()), }; assert_eq!(global(&trace), &expected, "{id}"); } @@ -218,7 +218,7 @@ fn boolean_number_and_vector_reads_come_from_state() { &set_global("Health(Host Player)"), &scenario(&player(r#""health": 175"#)), ); - assert_eq!(global(&trace), &Val::Number(175.0)); + assert_eq!(global(&trace), &Val::number(175.0)); let trace = run( &set_global("Velocity Of(Host Player)"), &scenario(&player(r#""velocity": { "x": 1, "y": 2, "z": 3 }"#)), @@ -239,7 +239,25 @@ fn speed_of_is_the_length_of_velocity() { &set_global("Speed Of(Host Player)"), &scenario(&player(r#""velocity": { "x": 3, "y": 4, "z": 0 }"#)), ); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); +} + +/// `Speed Of` squares the components before rooting: a component so +/// large the sum of squares overflows stops `not_modeled` instead of +/// serializing `null` (athena#133). +#[test] +fn speed_of_stops_on_a_non_finite_magnitude() { + let trace = run( + &set_global("Speed Of(Host Player)"), + &scenario(&player(r#""velocity": { "x": 1e308, "y": 1e308, "z": 0 }"#)), + ); + let Outcome::NotModeled(stop) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + stop.construct, + "value getSpeed produced a non-finite number" + ); } #[test] @@ -331,7 +349,7 @@ fn an_absent_state_field_keeps_the_established_models() { &values, ) .expect("the run resolves"); - assert_eq!(global(&trace), &Val::Number(250.0)); + assert_eq!(global(&trace), &Val::number(250.0)); let trace = run(&set_global("Is Moving(Host Player)"), &scenario); assert_eq!(trace.outcome, Outcome::MissingInput); assert_eq!( @@ -381,7 +399,7 @@ fn version_1_state_still_means_variables() { let scenario = r#"{ "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1", "host": true, "state": { "streak": 2 } }] }"#; let trace = run(PROGRAM_VARIABLE, scenario); - assert_eq!(global(&trace), &Val::Number(2.0)); + assert_eq!(global(&trace), &Val::number(2.0)); } #[test] @@ -389,7 +407,7 @@ fn version_2_reads_variables_from_vars() { let scenario = r#"{ "scenarioVersion": 2, "seconds": 1, "players": [{ "id": "p1", "host": true, "vars": { "streak": 3 } }] }"#; let trace = run(PROGRAM_VARIABLE, scenario); - assert_eq!(global(&trace), &Val::Number(3.0)); + assert_eq!(global(&trace), &Val::number(3.0)); } #[test] diff --git a/crates/athena-sim/tests/players_on_hero.rs b/crates/athena-sim/tests/players_on_hero.rs index 83230e5..db334ae 100644 --- a/crates/athena-sim/tests/players_on_hero.rs +++ b/crates/athena-sim/tests/players_on_hero.rs @@ -81,7 +81,7 @@ fn no_matching_player_is_the_empty_array() { ROSTER, ); assert_eq!(trace.outcome, Outcome::Complete, "{hero} on {team}"); - assert_eq!(trace.globals["g"], Val::Number(0.0), "{hero} on {team}"); + assert_eq!(trace.globals["g"], Val::number(0.0), "{hero} on {team}"); } } @@ -158,7 +158,7 @@ fn the_result_feeds_the_array_values() { &set_global("Count Of(Players On Hero(Hero(Ana), Team 1))"), ROSTER, ); - assert_eq!(trace.globals["g"], Val::Number(2.0)); + assert_eq!(trace.globals["g"], Val::number(2.0)); let trace = run( &set_global( diff --git a/crates/athena-sim/tests/random.rs b/crates/athena-sim/tests/random.rs index 277d2ba..50e5ef3 100644 --- a/crates/athena-sim/tests/random.rs +++ b/crates/athena-sim/tests/random.rs @@ -108,8 +108,9 @@ fn random_integer_is_inclusive_inside_the_bounds() { let Val::Number(n) = global(&trace) else { panic!("{bounds} seed {seed}: {:?}", trace.globals["g"]); }; + let n = n.get(); assert!( - *n >= lo && *n <= hi && n.fract() == 0.0, + n >= lo && n <= hi && n.fract() == 0.0, "{bounds} seed {seed}: {n}" ); } @@ -123,14 +124,14 @@ fn random_real_stays_inside_min_max() { let Val::Number(n) = global(&trace) else { panic!("seed {seed}: {:?}", trace.globals["g"]); }; - assert!(*n >= 2.0 && *n < 5.0, "seed {seed}: {n}"); + assert!(n.get() >= 2.0 && n.get() < 5.0, "seed {seed}: {n:?}"); } // Swapped bounds cover the same interval. let trace = seeded(&set_global("Random Real(5, 2)"), 1); let Val::Number(n) = global(&trace) else { panic!("{:?}", trace.globals["g"]); }; - assert!(*n >= 2.0 && *n < 5.0, "{n}"); + assert!(n.get() >= 2.0 && n.get() < 5.0, "{n:?}"); } #[test] @@ -167,7 +168,7 @@ fn randomized_array_keeps_the_elements() { let mut sorted: Vec = items .iter() .map(|item| match item { - Val::Number(n) => *n, + Val::Number(n) => n.get(), other => panic!("seed {seed}: {other:?}"), }) .collect(); @@ -180,7 +181,7 @@ fn randomized_array_keeps_the_elements() { ("Randomized Array(Null)", Val::Array(vec![])), ( "Randomized Array(Array(7))", - Val::Array(vec![Val::Number(7.0)]), + Val::Array(vec![Val::number(7.0)]), ), ] { let trace = seeded(&set_global(value), 1); diff --git a/crates/athena-sim/tests/scenario.rs b/crates/athena-sim/tests/scenario.rs index 5e859a8..72223fc 100644 --- a/crates/athena-sim/tests/scenario.rs +++ b/crates/athena-sim/tests/scenario.rs @@ -218,22 +218,22 @@ fn practice_range_completes_with_the_modeled_hud() { .iter() .all(|result| result.status == ExpectationStatus::Pass) ); - assert_eq!(trace.globals["score"], Val::Number(2.0)); + assert_eq!(trace.globals["score"], Val::number(2.0)); } #[test] fn practice_range_scenario_passes_when_the_hud_rule_is_not_present() { let trace = run(KILL_RULE, &example("practice-range.json")); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["score"], Val::Number(2.0)); - assert_eq!(trace.globals["delta"], Val::Number(2.0)); + assert_eq!(trace.globals["score"], Val::number(2.0)); + assert_eq!(trace.globals["delta"], Val::number(2.0)); let [score, streak] = trace.expectations.as_slice() else { panic!("expected two results, got {:?}", trace.expectations); }; assert_eq!(score.status, ExpectationStatus::Pass); - assert_eq!(score.actual, Val::Number(2.0)); + assert_eq!(score.actual, Val::number(2.0)); assert_eq!(streak.status, ExpectationStatus::Pass); - assert_eq!(streak.actual, Val::Number(3.0)); + assert_eq!(streak.actual, Val::number(3.0)); } #[test] @@ -242,7 +242,7 @@ fn an_undeclared_variable_starts_as_null() { assert_eq!(trace.outcome, Outcome::Complete); // `streak` starts as `null`, which reads as 0: after one final blow it is // 1, so `delta` is 1 and `score` goes to 1. - assert_eq!(trace.globals["score"], Val::Number(1.0)); + assert_eq!(trace.globals["score"], Val::number(1.0)); assert!( trace .expectations @@ -353,7 +353,7 @@ fn a_supplied_event_value_is_read() { }"#; let trace = run(DAMAGE_RULE, scenario); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["dmg"], Val::Number(12.5)); + assert_eq!(trace.globals["dmg"], Val::number(12.5)); } #[test] @@ -370,7 +370,7 @@ fn two_events_at_the_same_time_fire_in_file_order() { let trace = run(KILL_RULE, scenario); assert_eq!(trace.outcome, Outcome::Complete); // Two final blows: streak 0 + 1 + 1, delta 1 twice. - assert_eq!(trace.globals["score"], Val::Number(2.0)); + assert_eq!(trace.globals["score"], Val::number(2.0)); } #[test] @@ -417,7 +417,7 @@ fn a_failing_expectation_reports_the_observed_value() { panic!("expected two results, got {:?}", trace.expectations); }; assert_eq!(score.status, ExpectationStatus::Fail); - assert_eq!(score.actual, Val::Number(2.0)); + assert_eq!(score.actual, Val::number(2.0)); assert_eq!(undeclared.status, ExpectationStatus::Pass); } @@ -629,14 +629,16 @@ fn damage_consumes_pools_in_shield_armor_health_order_and_heal_caps() { // finishes the 30 armor left and takes 70 health, leaving 130. The // heals of 50 and 10 raise it to 190. let shield = world.shield.as_ref().expect("ana has a shield pool"); - assert_eq!((shield.current, shield.max), (0.0, 100.0)); + assert_eq!((shield.current.get(), shield.max.get()), (0.0, 100.0)); let armor = world.armor.as_ref().expect("ana has an armor pool"); - assert_eq!((armor.current, armor.max), (0.0, 50.0)); - assert_eq!((world.health.current, world.health.max), (190.0, 200.0)); + assert_eq!((armor.current.get(), armor.max.get()), (0.0, 50.0)); + assert_eq!(world.health.current.get(), 190.0); + assert_eq!(world.health.max.get(), 200.0); // Cooldowns and ultimate are read-only catalog values. - assert_eq!(world.cooldowns["ability1"], 10.0); - assert_eq!(world.cooldowns["ability2"], 12.0); - assert_eq!((world.ultimate.charge, world.ultimate.cost), (0.0, 2100.0)); + assert_eq!(world.cooldowns["ability1"].get(), 10.0); + assert_eq!(world.cooldowns["ability2"].get(), 12.0); + assert_eq!(world.ultimate.charge.get(), 0.0); + assert_eq!(world.ultimate.cost.get(), 2100.0); // `p2` was not the event player: its pools are untouched, and the // catalog records no shield or armor for it. @@ -644,10 +646,12 @@ fn damage_consumes_pools_in_shield_armor_health_order_and_heal_caps() { assert_eq!(p2.hero.as_deref(), Some("Ashe")); assert_eq!(p2.team.as_deref(), Some("team2")); let world = p2.world.as_ref().expect("p2 has world state"); - assert_eq!((world.health.current, world.health.max), (200.0, 200.0)); + assert_eq!(world.health.current.get(), 200.0); + assert_eq!(world.health.max.get(), 200.0); assert_eq!(world.shield, None); assert_eq!(world.armor, None); - assert_eq!((world.ultimate.charge, world.ultimate.cost), (0.0, 2400.0)); + assert_eq!(world.ultimate.charge.get(), 0.0); + assert_eq!(world.ultimate.cost.get(), 2400.0); } #[test] @@ -680,9 +684,10 @@ rule ("heal") { // 200 damage leaves 150 health (through the 100 shield and 50 armor). // The heal of 500 stops at the 200 maximum; the next heal has no effect. // Neither heal restores the shield or armor pools. - assert_eq!((world.health.current, world.health.max), (200.0, 200.0)); - assert_eq!(world.shield.as_ref().unwrap().current, 0.0); - assert_eq!(world.armor.as_ref().unwrap().current, 0.0); + assert_eq!(world.health.current.get(), 200.0); + assert_eq!(world.health.max.get(), 200.0); + assert_eq!(world.shield.as_ref().unwrap().current.get(), 0.0); + assert_eq!(world.armor.as_ref().unwrap().current.get(), 0.0); } #[test] diff --git a/crates/athena-sim/tests/server_load.rs b/crates/athena-sim/tests/server_load.rs index 6a678fe..590a851 100644 --- a/crates/athena-sim/tests/server_load.rs +++ b/crates/athena-sim/tests/server_load.rs @@ -45,7 +45,7 @@ fn each_value_returns_the_supplied_number() { ] { let trace = loaded(&set_global(value)); assert_eq!(trace.outcome, Outcome::Complete, "{value}"); - assert_eq!(trace.globals["g"], Val::Number(expected), "{value}"); + assert_eq!(trace.globals["g"], Val::number(expected), "{value}"); } } @@ -104,7 +104,7 @@ rule ("probe") { name: "g".into(), index: None, old: Val::Null, - new: Val::Number(1.0), + new: Val::number(1.0), }] ); } diff --git a/crates/athena-sim/tests/slot.rs b/crates/athena-sim/tests/slot.rs index c8580a8..0eddd50 100644 --- a/crates/athena-sim/tests/slot.rs +++ b/crates/athena-sim/tests/slot.rs @@ -55,7 +55,7 @@ fn slot_of_reads_the_declared_slot() { }"#; let trace = run(SLOT_OF, scenario); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["s"], Val::Number(5.0)); + assert_eq!(trace.globals["s"], Val::number(5.0)); } #[test] diff --git a/crates/athena-sim/tests/state_writes.rs b/crates/athena-sim/tests/state_writes.rs index f412991..b3c8985 100644 --- a/crates/athena-sim/tests/state_writes.rs +++ b/crates/athena-sim/tests/state_writes.rs @@ -61,8 +61,8 @@ fn a_state_write_defines_the_key_for_later_reads() { &scenario(PLAYER), ); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["g"], Val::Number(42.0)); - assert_eq!(trace.players["p1"].state["ultCharge"], Val::Number(42.0)); + assert_eq!(trace.globals["g"], Val::number(42.0)); + assert_eq!(trace.players["p1"].state["ultCharge"], Val::number(42.0)); } /// `True`/`False` coerce to `1`/`0` under the `Number|Boolean` @@ -76,7 +76,7 @@ fn set_ultimate_charge_accepts_the_boolean_coercion() { ), &scenario(PLAYER), ); - assert_eq!(trace.globals["g"], Val::Number(0.0)); + assert_eq!(trace.globals["g"], Val::number(0.0)); } /// A read of a key nothing wrote and the scenario does not supply is @@ -116,8 +116,8 @@ fn a_state_write_is_a_change_naming_player_and_key() { .expect("a state change"); assert_eq!(*change.0, "p1"); assert_eq!(*change.1, "ultCharge"); - assert_eq!(*change.2, Val::Number(12.0)); - assert_eq!(*change.3, Val::Number(40.0)); + assert_eq!(*change.2, Val::number(12.0)); + assert_eq!(*change.3, Val::number(40.0)); } /// A write on a key nothing answered records `old: null`. @@ -146,14 +146,14 @@ fn set_health_writes_living_players_only() { &program("Set Player Health(Host Player, 80);"), &scenario(HEALTHY), ); - assert_eq!(trace.players["p1"].state["health"], Val::Number(80.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(80.0)); let dead = scenario( r#"{ "id": "p1", "host": true, "hero": "ana", "state": { "health": 175, "maxHealth": 200, "isDead": true } }"#, ); let trace = run(&program("Set Player Health(Host Player, 80);"), &dead); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.players["p1"].state["health"], Val::Number(175.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(175.0)); assert!( !trace.timeline.entries[0] .changes @@ -170,8 +170,8 @@ fn set_max_health_scales_and_clamps_health() { &program("Set Max Health(Host Player, 50);"), &scenario(HEALTHY), ); - assert_eq!(trace.players["p1"].state["maxHealth"], Val::Number(100.0)); - assert_eq!(trace.players["p1"].state["health"], Val::Number(100.0)); + assert_eq!(trace.players["p1"].state["maxHealth"], Val::number(100.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(100.0)); let trace = run( &program("Set Max Health(Host Player, 50);"), &scenario( @@ -179,7 +179,25 @@ fn set_max_health_scales_and_clamps_health() { "state": { "health": 50, "maxHealth": 200, "isDead": false } }"#, ), ); - assert_eq!(trace.players["p1"].state["health"], Val::Number(50.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(50.0)); +} + +/// `Set Max Health` scales the max by the percent: a product that +/// overflows to `inf` stops `not_modeled` instead of writing a value +/// that serializes `null` (athena#133). +#[test] +fn set_max_health_stops_on_a_non_finite_result() { + let trace = run( + &program("Set Max Health(Host Player, Raise To Power(10, 308));"), + &scenario(HEALTHY), + ); + let Outcome::NotModeled(stop) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!( + stop.construct, + "action setMaxHealth produced a non-finite number" + ); } /// `Kill` writes `isDead`; `Respawn` clears it and restores `health` @@ -190,10 +208,10 @@ fn kill_respawn_and_resurrect_write_is_dead() { assert_eq!(dead.players["p1"].state["isDead"], Val::Bool(true)); let trace = run(&program("Respawn(Host Player);"), &dead_scenario()); assert_eq!(trace.players["p1"].state["isDead"], Val::Bool(false)); - assert_eq!(trace.players["p1"].state["health"], Val::Number(200.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(200.0)); let trace = run(&program("Resurrect(Host Player);"), &dead_scenario()); assert_eq!(trace.players["p1"].state["isDead"], Val::Bool(false)); - assert_eq!(trace.players["p1"].state["health"], Val::Number(0.0)); + assert_eq!(trace.players["p1"].state["health"], Val::number(0.0)); } fn dead_scenario() -> String { @@ -250,7 +268,7 @@ fn set_facing_writes_the_scenario_facing() { ), &scenario(PLAYER), ); - assert_eq!(trace.globals["g"], Val::Number(0.0)); + assert_eq!(trace.globals["g"], Val::number(0.0)); let change = trace.timeline.entries[0] .changes .iter() @@ -303,50 +321,50 @@ fn every_stored_modifier_records_its_effect() { "Set Damage Dealt(Host Player, 20)", "setDamageDealt", "damageDealt", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Damage Received(Host Player, 20)", "setDamageReceived", "damageReceived", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Healing Dealt(Host Player, 20)", "setHealingDealt", "healingDealt", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Healing Received(Host Player, 20)", "setHealingReceived", "healingReceived", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Move Speed(Host Player, 20)", "setMoveSpeed", "moveSpeed", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Gravity(Host Player, 20)", "setGravity", "gravity", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Knockback Received(Host Player, 20)", "setKnockbackReceived", "knockbackReceived", - Val::Number(20.0), - vec![Val::Number(20.0)], + Val::number(20.0), + vec![Val::number(20.0)], ), ( "Set Player Allowed Heroes(Host Player, Array(Hero(Ana)))", @@ -366,8 +384,8 @@ fn every_stored_modifier_records_its_effect() { "Start Forcing Spawn Room(Team 1, 0)", "startForcingSpawn", "forcedSpawnRoom", - Val::Number(0.0), - vec![Val::Number(0.0)], + Val::number(0.0), + vec![Val::number(0.0)], ), ]; for (call, id, key, stored, args) in cases { @@ -445,7 +463,7 @@ fn start_forcing_spawn_targets_the_team() { ); assert_eq!( trace.players["p2"].modifiers["forcedSpawnRoom"], - Val::Number(0.0) + Val::number(0.0) ); } @@ -460,7 +478,7 @@ fn a_stored_modifier_changes_no_read() { ), &scenario(r#"{ "id": "p1", "host": true, "hero": "ana", "state": { "ultCharge": 30 } }"#), ); - assert_eq!(trace.globals["g"], Val::Number(30.0)); + assert_eq!(trace.globals["g"], Val::number(30.0)); } /// The class-3 actions still stop `not_modeled`: each moves a value or diff --git a/crates/athena-sim/tests/status_buttons_effects.rs b/crates/athena-sim/tests/status_buttons_effects.rs index 928d206..f41c5d5 100644 --- a/crates/athena-sim/tests/status_buttons_effects.rs +++ b/crates/athena-sim/tests/status_buttons_effects.rs @@ -244,8 +244,8 @@ rule ("Subroutine sub") { "#; let trace = run(text, ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); - assert_eq!(trace.globals["b"], Val::Number(2.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); + assert_eq!(trace.globals["b"], Val::number(2.0)); let entry = &trace.timeline.entries[0]; assert!( entry @@ -301,13 +301,13 @@ rule ("Subroutine sub") {{ // Do Nothing: the second start is dropped while `sub` waits. let trace = run(&program("Do Nothing"), ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["c"], Val::Number(1.0)); - assert_eq!(trace.globals["b"], Val::Number(1.0)); + assert_eq!(trace.globals["c"], Val::number(1.0)); + assert_eq!(trace.globals["b"], Val::number(1.0)); // Restart Rule: the second start re-runs the head, so `c` counts twice. let trace = run(&program("Restart Rule"), ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["c"], Val::Number(2.0)); - assert_eq!(trace.globals["b"], Val::Number(1.0)); + assert_eq!(trace.globals["c"], Val::number(2.0)); + assert_eq!(trace.globals["b"], Val::number(1.0)); } /// A `Wait Until` inside a started subroutine re-checks its own @@ -353,7 +353,7 @@ rule ("Subroutine sub") { let Val::Number(done) = trace.globals["done"] else { panic!("expected the subroutine to finish, got {:?}", trace.globals) }; - assert!(done < 1.0, "resumed at {done}s"); + assert!(done.get() < 1.0, "resumed at {done:?}s"); } /// The simulator never starts a chase, so `Stop Chasing` has nothing to @@ -380,7 +380,7 @@ rule ("t") { "#; let trace = run(text, ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["a"], Val::Number(1.0)); + assert_eq!(trace.globals["a"], Val::number(1.0)); } /// `Create Effect` records an entity like a HUD element — recipients and @@ -408,7 +408,7 @@ rule ("init") { "#; let trace = run(text, ANA); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["e"], Val::Number(0.0)); + assert_eq!(trace.globals["e"], Val::number(0.0)); let [created, played] = trace.effects.as_slice() else { panic!("expected two effect entities, got {:?}", trace.effects); }; @@ -417,7 +417,7 @@ rule ("init") { assert_eq!(created.args.effect.as_deref(), Some("GOOD_AURA")); assert_eq!(created.args.color, Some(Val::Text("WHITE".into()))); assert_eq!(created.args.position, Some(Val::Player("p1".into()))); - assert_eq!(created.args.radius, Some(Val::Number(2.0))); + assert_eq!(created.args.radius, Some(Val::number(2.0))); assert_eq!( created.args.reevaluation.as_deref(), Some("POSITION_AND_RADIUS") diff --git a/crates/athena-sim/tests/strings.rs b/crates/athena-sim/tests/strings.rs index 4844cff..804821e 100644 --- a/crates/athena-sim/tests/strings.rs +++ b/crates/athena-sim/tests/strings.rs @@ -128,7 +128,7 @@ fn index_of_string_char_finds_the_first_index_or_minus_one() { ), ] { let trace = run(&set_global(value)); - assert_eq!(global(&trace), &Val::Number(expected), "{value}"); + assert_eq!(global(&trace), &Val::number(expected), "{value}"); } } diff --git a/crates/athena-sim/tests/timeline.rs b/crates/athena-sim/tests/timeline.rs index 557ef5e..9012bc2 100644 --- a/crates/athena-sim/tests/timeline.rs +++ b/crates/athena-sim/tests/timeline.rs @@ -38,7 +38,7 @@ fn writes_around_a_wait_land_on_their_ticks() { panic!("expected two entries, got {:?}", trace.timeline.entries); }; assert_eq!(start.tick, 0); - assert_eq!(start.time, 15.0); + assert_eq!(start.time.get(), 15.0); let [run] = start.rules.as_slice() else { panic!("expected one rule run, got {:?}", start.rules); }; @@ -52,11 +52,11 @@ fn writes_around_a_wait_land_on_their_ticks() { name: "a".into(), index: None, old: Val::Null, - new: Val::Number(1.0), + new: Val::number(1.0), }] ); assert_eq!(resume.tick, 32); - assert_eq!(resume.time, 527.0); + assert_eq!(resume.time.get(), 527.0); let [run] = resume.rules.as_slice() else { panic!("expected one rule run, got {:?}", resume.rules); }; @@ -66,8 +66,8 @@ fn writes_around_a_wait_land_on_their_ticks() { [Change::GlobalVariable { name: "a".into(), index: None, - old: Val::Number(1.0), - new: Val::Number(2.0), + old: Val::number(1.0), + new: Val::number(2.0), }] ); } @@ -109,8 +109,8 @@ rule ("init") { [Change::GlobalVariable { name: "a".into(), index: None, - old: Val::Number(1.0), - new: Val::Number(2.0), + old: Val::number(1.0), + new: Val::number(2.0), }] ); } @@ -296,7 +296,7 @@ rule ("init") { name: "s".into(), index: None, old: Val::Null, - new: Val::Number(2.0), + new: Val::number(2.0), } ); assert_eq!( @@ -311,8 +311,8 @@ rule ("init") { Change::World { player: "p1".into(), field: "health".into(), - old: Val::Number(200.0), - new: Val::Number(190.0), + old: Val::number(200.0), + new: Val::number(190.0), } ); } diff --git a/crates/athena-sim/tests/values.rs b/crates/athena-sim/tests/values.rs index e37489b..ffb2a3d 100644 --- a/crates/athena-sim/tests/values.rs +++ b/crates/athena-sim/tests/values.rs @@ -46,11 +46,11 @@ fn if_then_else_reads_only_the_taken_branch() { let trace = run(&set_global( "If-Then-Else(Compare(1, <, 2), 10, Position Of(Null))", )); - assert_eq!(global(&trace), &Val::Number(10.0)); + assert_eq!(global(&trace), &Val::number(10.0)); // A `Null` condition reads as `0`, so the else branch wins. let trace = run(&set_global("If-Then-Else(Null, 10, 20)")); - assert_eq!(global(&trace), &Val::Number(20.0)); + assert_eq!(global(&trace), &Val::number(20.0)); } #[test] @@ -98,7 +98,7 @@ fn divide_modulo_min_max_and_raise_to_power_do_arithmetic() { ("Raise To Power(2, 10)", 1024.0), ] { let trace = run(&set_global(value)); - assert_eq!(global(&trace), &Val::Number(expected), "{value}"); + assert_eq!(global(&trace), &Val::number(expected), "{value}"); } } @@ -111,7 +111,7 @@ fn round_to_integer_follows_the_rounding_member() { ("Round To Integer(-2.5, Nearest)", -3.0), ] { let trace = run(&set_global(value)); - assert_eq!(global(&trace), &Val::Number(expected), "{value}"); + assert_eq!(global(&trace), &Val::number(expected), "{value}"); } } @@ -153,11 +153,11 @@ rule ("probe") {{ assert_not_modeled(&trace, "number literal produced a non-finite number"); } -/// `Round To Integer` routes its result through the finite check: a -/// non-finite operand — here a variable the `For` `End` step left -/// `inf` — stops `not_modeled` instead of serializing `null`. +/// A `For` `End` step routes its written value through the finite check: +/// `old + step` overflowing to `inf` stops `not_modeled` at the `End` +/// instead of storing a value that would serialize `null` (athena#133). #[test] -fn round_to_integer_stops_on_a_non_finite_operand() { +fn for_end_stops_on_a_non_finite_increment() { let trace = run(r#" variables { global: @@ -176,13 +176,56 @@ rule ("probe") { } } "#); - assert_not_modeled(&trace, "roundToInteger produced a non-finite number"); + assert_not_modeled(&trace, "forGlobalVariable produced a non-finite number"); +} + +/// `Val::Number` can only hold a `Finite`: the serialized trace can +/// never carry `{"type":"number","value":null}` — every produced +/// number is checked before it becomes a value (athena#133). +#[test] +fn a_serialized_trace_never_carries_a_null_number() { + // The scenario trace covers the structures a `number` can live in: + // globals, change `old`/`new`s, clock and pool fields, event + // values, the timeline `time`. + let program = r#" +variables { + global: + 0: g +} + +rule ("probe") { + event { + Player Joined Match; + All; + All; + } + actions { + Damage(Event Player, Null, 25); + Set Slow Motion(50); + Set Match Time(60); + Set Global Variable(g, Add(1, 2)); + } +} +"#; + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [{ "id": "p1", "host": true, "hero": "ana" }], + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] + }"#; + for trace in [ + run(&set_global("Add(1, 2)")), + run_scenario(program, scenario).unwrap_or_else(|error| panic!("{error}")), + ] { + let json = serde_json::to_string(&trace).expect("trace serializes"); + assert!(!json.contains(r#"{"type":"number","value":null}"#)); + } } #[test] fn first_of_and_count_of_see_null_as_the_empty_array() { let trace = run(&set_global("First Of(Array(5, 6))")); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); let trace = run(&set_global("First Of(Empty Array)")); assert_eq!(global(&trace), &Val::Null); @@ -191,10 +234,10 @@ fn first_of_and_count_of_see_null_as_the_empty_array() { assert_eq!(global(&trace), &Val::Null); let trace = run(&set_global("Count Of(Array(1, 2, 3))")); - assert_eq!(global(&trace), &Val::Number(3.0)); + assert_eq!(global(&trace), &Val::number(3.0)); let trace = run(&set_global("Count Of(Null)")); - assert_eq!(global(&trace), &Val::Number(0.0)); + assert_eq!(global(&trace), &Val::number(0.0)); } /// `Last Of` is the array tail, symmetric to `First Of` (athena#105): @@ -203,10 +246,10 @@ fn first_of_and_count_of_see_null_as_the_empty_array() { #[test] fn last_of_sees_null_as_the_empty_array() { let trace = run(&set_global("Last Of(Array(5, 6))")); - assert_eq!(global(&trace), &Val::Number(6.0)); + assert_eq!(global(&trace), &Val::number(6.0)); let trace = run(&set_global("Last Of(Array(5))")); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); let trace = run(&set_global("Last Of(Empty Array)")); assert_eq!(global(&trace), &Val::Null); @@ -228,7 +271,11 @@ fn sine_from_radians_evaluates_its_argument() { let Val::Number(sine) = global(&trace) else { panic!("{value}: {:?}", global(&trace)); }; - assert!((sine - expected).abs() < 1e-9, "{value}: {sine}"); + assert!( + (sine.get() - expected).abs() < 1e-9, + "{value}: {}", + sine.get() + ); } } @@ -239,7 +286,7 @@ fn sine_from_radians_evaluates_its_argument() { #[test] fn value_in_array_yields_null_when_no_element_exists() { let trace = run(&set_global("Value In Array(Array(1, 2), 0)")); - assert_eq!(global(&trace), &Val::Number(1.0)); + assert_eq!(global(&trace), &Val::number(1.0)); // Out-of-range and negative indexes already yielded `Null`. let trace = run(&set_global("Value In Array(Array(1, 2), 9)")); @@ -295,13 +342,13 @@ fn array_contains_and_index_of_array_value_search_by_compare_equality() { assert_eq!(global(&trace), &Val::Bool(true)); let trace = run(&set_global("Index Of Array Value(Array(4, 5), 5)")); - assert_eq!(global(&trace), &Val::Number(1.0)); + assert_eq!(global(&trace), &Val::number(1.0)); let trace = run(&set_global("Index Of Array Value(Array(4, 5), 9)")); - assert_eq!(global(&trace), &Val::Number(-1.0)); + assert_eq!(global(&trace), &Val::number(-1.0)); let trace = run(&set_global("Index Of Array Value(Null, 9)")); - assert_eq!(global(&trace), &Val::Number(-1.0)); + assert_eq!(global(&trace), &Val::number(-1.0)); } #[test] @@ -309,7 +356,7 @@ fn array_slice_clamps_its_bounds() { let trace = run(&set_global("Array Slice(Array(1, 2, 3, 4), 1, 2)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(2.0), Val::Number(3.0)]) + &Val::Array(vec![Val::number(2.0), Val::number(3.0)]) ); // A start past the end yields the empty array. @@ -320,7 +367,7 @@ fn array_slice_clamps_its_bounds() { let trace = run(&set_global("Array Slice(Array(1, 2, 3), 1, 99)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(2.0), Val::Number(3.0)]) + &Val::Array(vec![Val::number(2.0), Val::number(3.0)]) ); } @@ -329,11 +376,11 @@ fn append_to_array_grows_arrays_and_null() { let trace = run(&set_global("Append To Array(Array(1), 2)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(1.0), Val::Number(2.0)]) + &Val::Array(vec![Val::number(1.0), Val::number(2.0)]) ); let trace = run(&set_global("Append To Array(Null, 2)")); - assert_eq!(global(&trace), &Val::Array(vec![Val::Number(2.0)])); + assert_eq!(global(&trace), &Val::Array(vec![Val::number(2.0)])); } /// `Remove From Array` drops every element equal to the operand — the @@ -345,7 +392,7 @@ fn remove_from_array_drops_every_match() { let trace = run(&set_global("Remove From Array(Array(1, 2, 1, 3), 1)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(2.0), Val::Number(3.0)]) + &Val::Array(vec![Val::number(2.0), Val::number(3.0)]) ); // `Null` reads as the empty array. @@ -356,18 +403,18 @@ fn remove_from_array_drops_every_match() { let trace = run(&set_global("Remove From Array(Array(1, 2), 9)")); assert_eq!( global(&trace), - &Val::Array(vec![Val::Number(1.0), Val::Number(2.0)]) + &Val::Array(vec![Val::number(1.0), Val::number(2.0)]) ); // `Null` equals `0` under the `Compare` coercion. let trace = run(&set_global("Remove From Array(Array(Null, 0, 1), Null)")); - assert_eq!(global(&trace), &Val::Array(vec![Val::Number(1.0)])); + assert_eq!(global(&trace), &Val::Array(vec![Val::number(1.0)])); // An array operand drops each element it holds. let trace = run(&set_global( "Remove From Array(Array(1, 2, 3, 2), Array(2, 3))", )); - assert_eq!(global(&trace), &Val::Array(vec![Val::Number(1.0)])); + assert_eq!(global(&trace), &Val::Array(vec![Val::number(1.0)])); // The Bastion form: a player operand out of a player array. let scenario = r#"{ @@ -392,28 +439,28 @@ fn remove_from_array_drops_every_match() { #[test] fn modify_global_variable_covers_the_operator_set() { for (op, initial, operand, expected) in [ - ("Divide", "10", "4", Val::Number(2.5)), - ("Modulo", "10", "4", Val::Number(2.0)), - ("Min", "10", "4", Val::Number(4.0)), - ("Max", "10", "4", Val::Number(10.0)), - ("Raise To Power", "2", "8", Val::Number(256.0)), + ("Divide", "10", "4", Val::number(2.5)), + ("Modulo", "10", "4", Val::number(2.0)), + ("Min", "10", "4", Val::number(4.0)), + ("Max", "10", "4", Val::number(10.0)), + ("Raise To Power", "2", "8", Val::number(256.0)), ( "Append To Array", "Null", "7", - Val::Array(vec![Val::Number(7.0)]), + Val::Array(vec![Val::number(7.0)]), ), ( "Remove From Array By Value", "Array(1, 2, 1, 3)", "1", - Val::Array(vec![Val::Number(2.0), Val::Number(3.0)]), + Val::Array(vec![Val::number(2.0), Val::number(3.0)]), ), ( "Remove From Array By Index", "Array(1, 2, 3)", "9", - Val::Array(vec![Val::Number(1.0), Val::Number(2.0), Val::Number(3.0)]), + Val::Array(vec![Val::number(1.0), Val::number(2.0), Val::number(3.0)]), ), ] { let text = format!( @@ -474,13 +521,13 @@ fn at_index_writes_append_clamp_or_skip() { // the end appends, index 1 replaces that element. assert_eq!( trace.globals["g"], - Val::Array(vec![Val::Number(10.0), Val::Number(15.0)]) + Val::Array(vec![Val::number(10.0), Val::number(15.0)]) ); assert_eq!(trace.globals["skipped"], Val::Null); // Index 1 is modified; index 9 writes nothing. assert_eq!( trace.globals["m"], - Val::Array(vec![Val::Number(1.0), Val::Number(12.0)]) + Val::Array(vec![Val::number(1.0), Val::number(12.0)]) ); // The element writes record their effective index. let indices: Vec<_> = trace @@ -625,5 +672,5 @@ rule ("probe") { assert_eq!(trace.globals["same"], Val::Bool(true)); assert_eq!(trace.globals["other"], Val::Bool(false)); assert_eq!(trace.globals["inside"], Val::Bool(true)); - assert_eq!(trace.globals["members"], Val::Number(1.0)); + assert_eq!(trace.globals["members"], Val::number(1.0)); } diff --git a/crates/athena-sim/tests/vectors.rs b/crates/athena-sim/tests/vectors.rs index 3005b06..ba81c82 100644 --- a/crates/athena-sim/tests/vectors.rs +++ b/crates/athena-sim/tests/vectors.rs @@ -85,7 +85,7 @@ fn vector_builds_and_its_components_read_back() { ("Z Component Of", 3.0), ] { let trace = run(&set_global(&format!("{component}(Vector(1, 2, 3))"))); - assert_eq!(global(&trace), &Val::Number(expected), "{component}"); + assert_eq!(global(&trace), &Val::number(expected), "{component}"); } } @@ -99,18 +99,18 @@ fn a_null_operand_in_a_vector_position_is_the_zero_vector() { // `g` is declared but never set. for component in ["X Component Of", "Y Component Of", "Z Component Of"] { let trace = run(&set_global(&format!("{component}(Global.g)"))); - assert_eq!(global(&trace), &Val::Number(0.0), "{component}"); + assert_eq!(global(&trace), &Val::number(0.0), "{component}"); } // A literal `Null` operand evaluates to the same `Null`. let trace = run(&set_global("Y Component Of(Null)")); - assert_eq!(global(&trace), &Val::Number(0.0)); + assert_eq!(global(&trace), &Val::number(0.0)); // The other strict `Vector` positions read the same zero vector; // the zero-length guards still stop where they did. let trace = run(&set_global("Distance Between(Global.g, Vector(3, 4, 0))")); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); let trace = run(&set_global("Dot Product(Global.g, Vector(1, 2, 3))")); - assert_eq!(global(&trace), &Val::Number(0.0)); + assert_eq!(global(&trace), &Val::number(0.0)); let trace = run(&set_global("Normalize(Global.g)")); assert_not_modeled(&trace, "normalize of a zero-length vector"); @@ -145,8 +145,8 @@ rule ("probe") { "#; let trace = run(text); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["before"], Val::Number(0.0)); - assert_eq!(trace.globals["after"], Val::Number(2.0)); + assert_eq!(trace.globals["before"], Val::number(0.0)); + assert_eq!(trace.globals["after"], Val::number(2.0)); } #[test] @@ -174,7 +174,7 @@ fn distance_between_is_the_displacement_magnitude() { let trace = run(&set_global( "Distance Between(Vector(1, 2, 3), Vector(4, 6, 3))", )); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); } #[test] @@ -210,8 +210,8 @@ fn angles_from_direction_invert_the_frame() { panic!("{:?}", trace.globals["g"]) }; assert!( - (h - horizontal).abs() < 1e-9, - "{direction}: {h} != {horizontal}" + (h.get() - horizontal).abs() < 1e-9, + "{direction}: {h:?} != {horizontal}" ); let trace = run(&set_global(&format!( "Vertical Angle From Direction({direction})" @@ -220,8 +220,8 @@ fn angles_from_direction_invert_the_frame() { panic!("{:?}", trace.globals["g"]) }; assert!( - (v - vertical).abs() < 1e-9, - "{direction}: {v} != {vertical}" + (v.get() - vertical).abs() < 1e-9, + "{direction}: {v:?} != {vertical}" ); } } @@ -229,7 +229,7 @@ fn angles_from_direction_invert_the_frame() { #[test] fn dot_and_cross_product() { let trace = run(&set_global("Dot Product(Vector(1, 2, 3), Vector(4, 5, 6))")); - assert_eq!(global(&trace), &Val::Number(32.0)); + assert_eq!(global(&trace), &Val::number(32.0)); // x̂ × ŷ = ẑ. let trace = run(&set_global( "Cross Product(Vector(1, 0, 0), Vector(0, 1, 0))", @@ -246,7 +246,7 @@ fn normalize_and_magnitude() { let trace = run(&set_global("Normalize(Vector(3, 0, 4))")); assert_eq!(global(&trace), &vec(0.6, 0.0, 0.8)); let trace = run(&set_global("Magnitude Of(Vector(3, 0, 4))")); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); // A zero-length vector has no unit direction; unmeasured. let trace = run(&set_global("Normalize(Vector(0, 0, 0))")); @@ -275,8 +275,8 @@ fn vector_arithmetic_is_componentwise_with_scalar_broadcast() { ("Subtract(10, Vector(1, 2, 3))", vec(9.0, 8.0, 7.0)), ("Divide(1, Vector(1, 2, 4))", vec(1.0, 0.5, 0.25)), // Purely numeric operands still yield a number. - ("Add(2, 3)", Val::Number(5.0)), - ("Divide(10, 4)", Val::Number(2.5)), + ("Add(2, 3)", Val::number(5.0)), + ("Divide(10, 4)", Val::number(2.5)), ] { let trace = run(&set_global(value)); assert_eq!(global(&trace), &expected, "{value}"); @@ -328,7 +328,7 @@ fn modify_variable_arithmetic_is_the_same_vector_math() { "Vector(4, 5, 6)", vec(1.0, 2.0, 3.0), ), - ("2", "Raise To Power", "8", Val::Number(256.0)), + ("2", "Raise To Power", "8", Val::number(256.0)), ] { let trace = run(&modify_global(initial, op, operand)); assert_eq!(global(&trace), &expected, "{op} on {initial}"); @@ -360,7 +360,7 @@ rule ("probe") { let trace = run(text); assert_eq!( global(&trace), - &Val::Array(vec![vec(11.0, 22.0, 33.0), Val::Number(0.0)]) + &Val::Array(vec![vec(11.0, 22.0, 33.0), Val::number(0.0)]) ); } @@ -459,8 +459,8 @@ fn angle_between_vectors_reports_degrees() { panic!("{:?}", trace.globals["g"]) }; assert!( - (angle - expected).abs() < 1e-9, - "{value}: {angle} != {expected}" + (angle.get() - expected).abs() < 1e-9, + "{value}: {angle:?} != {expected}" ); } // A zero-length vector has no direction. @@ -500,7 +500,7 @@ fn position_of_reads_the_scenario_field() { &set_global("Distance Between(Host Player, Vector(4, 6, 3))"), r#"{ "id": "p1", "host": true, "position": { "x": 1, "y": 2, "z": 3 } }"#, ); - assert_eq!(global(&trace), &Val::Number(5.0)); + assert_eq!(global(&trace), &Val::number(5.0)); let trace = run_with( &set_global("Add(Host Player, Vector(1, 1, 1))"), r#"{ "id": "p1", "host": true, "position": { "x": 1, "y": 2, "z": 3 } }"#, @@ -671,7 +671,7 @@ rule ("probe") { } "#; let trace = run(text); - assert_eq!(global(&trace), &Val::Number(1.0)); + assert_eq!(global(&trace), &Val::number(1.0)); } /// An ambiguous spelling in a position the catalog does not type as a @@ -726,7 +726,7 @@ fn vertical_facing_angle_of_reads_the_scenario_facing() { &set_global("Vertical Facing Angle Of(Host Player)"), r#"{ "id": "p1", "host": true, "facing": { "horizontal": 90, "vertical": 45 } }"#, ); - assert_eq!(global(&trace), &Val::Number(45.0)); + assert_eq!(global(&trace), &Val::number(45.0)); // A player without `facing` is the same missing input // `Facing Direction Of` reports. diff --git a/crates/athena-sim/tests/workshop_settings.rs b/crates/athena-sim/tests/workshop_settings.rs index a391500..aec447f 100644 --- a/crates/athena-sim/tests/workshop_settings.rs +++ b/crates/athena-sim/tests/workshop_settings.rs @@ -35,10 +35,10 @@ rule ("defaults") { fn each_setting_kind_evaluates_to_its_default() { let trace = run(DEFAULTS); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["real"], Val::Number(3.5)); - assert_eq!(trace.globals["int"], Val::Number(7.0)); + assert_eq!(trace.globals["real"], Val::number(3.5)); + assert_eq!(trace.globals["int"], Val::number(7.0)); assert_eq!(trace.globals["toggle"], Val::Bool(true)); - assert_eq!(trace.globals["combo"], Val::Number(2.0)); + assert_eq!(trace.globals["combo"], Val::number(2.0)); assert_eq!(trace.globals["hero"], Val::Text("ANA".into())); } @@ -67,7 +67,7 @@ rule ("gated") { fn a_setting_reads_inside_a_condition_and_an_action_argument() { let trace = run(CONDITION_AND_ARGUMENT); assert_eq!(trace.outcome, Outcome::Complete); - assert_eq!(trace.globals["fired"], Val::Number(1.0)); + assert_eq!(trace.globals["fired"], Val::number(1.0)); assert_eq!(trace.logs.len(), 1); assert_eq!(trace.logs[0].text, "bonus 25.00"); } diff --git a/docs/findings.md b/docs/findings.md index 23935a1..6cef97f 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -35,7 +35,7 @@ Simulator choices no capture backs yet — the detail lives in [architecture.md] - `For Global Variable`/`For Player Variable` execution: bound evaluation points, the exit-check direction by step sign, and the `End` jump back to the first body action (athena#50). - `Workshop Setting *` values (`Real`, `Integer`, `Toggle`, `Combo`, `Hero`) evaluate to their declared `default` argument — one lobby, no player-edited settings; a `Hero` default yields its member id as text (athena#54). - Control-flow actions (athena#58): `Skip`/`Skip If` (a positional jump — it can land inside a block the jump never entered; a `True`/`False` count coerces to 1/0 and a negative count skips nothing), `Abort`/`Abort If` (ends the whole run, subroutine frames included; the timeline records it `aborted`), `Loop`/`Loop If`/`Loop If Condition Is True`/`Loop If Condition Is False` (restart the current frame's action list — inside `Call Subroutine`, the subroutine's own — with the condition list re-checked per pass), and `Break`/`Continue` inside `While`/`For` (`Continue` lands on the `End`, which steps a `For` or re-enters a `While` head; `Break` lands past it; outside a loop they are `not_modeled`). -- General and array values plus `At Index` variable writes (athena#56): truthiness and `Null`/boolean number coercions, `==`/`!=` element-wise array equality, `Round To Integer` modes, `Null` read as the empty array, past-end append and out-of-range no-op index writes, `Remove From Array By Value` dropping every match. `Number Of Players` counts the scenario players on the team — `All Teams` counting every player — the same roster `All Players` enumerates and no action changes; the `team` argument resolves to a catalog `Team` member id as `All Players`' does, an evaluated non-member is `not_modeled`. The sibling roster reads `All Living Players` and `Players In Slot` stay `not_modeled`. A non-finite produced number — a `Divide` or `Modulo` by zero, an overflowing `Raise To Power`, a `Round To Integer` of one — is `not_modeled`, the same stop a non-finite produced vector component forces; a `.ow` number literal whose digit run overflows `f64` stops at evaluation the same way. Known gap: non-finite values from producers outside this issue's constructs — `Speed Of`, the match clock's `elapsed` accumulation (`Total Time Elapsed`, the timeline `time`, `Has Status`' `until`), the `For`/`For Player Variable` `End` step's variable write, `Set Max Health`, the `Damage`/`Heal` applied values, `Set Ability Cooldown`, `Set Ultimate Charge` and the `hero_settings` percent multipliers — can still reach a store or `Change` record and serialize as `null`; sealing them needs a structural guard and is tracked separately. +- General and array values plus `At Index` variable writes (athena#56): truthiness and `Null`/boolean number coercions, `==`/`!=` element-wise array equality, `Round To Integer` modes, `Null` read as the empty array, past-end append and out-of-range no-op index writes, `Remove From Array By Value` dropping every match. `Number Of Players` counts the scenario players on the team — `All Teams` counting every player — the same roster `All Players` enumerates and no action changes; the `team` argument resolves to a catalog `Team` member id as `All Players`' does, an evaluated non-member is `not_modeled`. The sibling roster reads `All Living Players` and `Players In Slot` stay `not_modeled`. A non-finite produced number — a `Divide` or `Modulo` by zero, an overflowing `Raise To Power`, a `Round To Integer` of one — is `not_modeled`, the same stop a non-finite produced vector component forces; a `.ow` number literal whose digit run overflows `f64` stops at evaluation the same way. The producers this coverage left open are sealed (athena#133): every number that enters `Val`, a store or a `Change` record is a `Finite` built through one checked construction, so a non-finite result — `Speed Of`'s magnitude, the match clock's `elapsed` accumulation (`Total Time Elapsed`, the timeline `time`, `Has Status`' `until`), a `For`/`For Player Variable` `End` step's variable write, `Set Max Health`, a `Damage`/`Heal` applied value, `Set Ability Cooldown`, `Set Ultimate Charge` or a `hero_settings` percent multiplier — stops `not_modeled` naming the producing construct instead of serializing as `null`. - Element-context array values and `Evaluate Once` (athena#60): `Filtered Array`/`Sorted Array`/`Is True For All`/`Is True For Any` bind `Current Array Element` and `Current Array Index` per element and read `Null` as the empty array, `Sorted Array` orders ascending by a numeric key with equal keys keeping place, `Is True For All`/`Any` stop at the deciding element and read the empty array as true/false, `Current Array Element`/`Index` outside an element context are `not_modeled`, and `Evaluate Once` pins its operand's first read per site for the rest of one scope — a rule-instance run (loop bodies and restarted frames re-read the site into the pin; a `Wait` keeps the pins, and the next run start or restart clears them), one rule-condition check pass, or one `Wait Until` call's per-tick re-checks — and is `not_modeled` inside an element-context operand. `Mapped Array` (athena#134) joins them: it collects the operand's result for each element — the same per-element binding and `Null`-as-empty read — and a non-finite number among the results stops `not_modeled` through the produced-number check. - Statuses, buttons, effects and `Start Rule` (rest of athena#58): `Set Status`/`Clear Status`/`Has Status` expiries on `Total Time Elapsed`, `Allow`/`Disallow Button` writes, `Is Button Held` as a live scenario input (`buttons`), `Start Rule` timing and `ifAlreadyExecuting` behavior, `Create`/`Play`/`Destroy Effect` entities recorded like HUD without geometry, `Last Created Entity`, and the `Stop Chasing` no-op. - Random values (athena#65): the client's generator and distribution are unmeasured, so the scenario supplies the source — `random.seed` drives a splitmix64 stream the `Random *` values consume in evaluation order, one fraction `u ∈ [0, 1)` per draw. `Random Integer` floors its bounds and is inclusive (a `Min` past `Max` swaps them), `Random Real` is `min + (max−min)·u` on `[min, max)`, `Random Value In Array` on an empty array yields `Null` without a draw, and `Randomized Array` is a Fisher-Yates shuffle of the same stream. A random value without a `random` source is a `missing_input`, and each draw is recorded as a `randomDraw` change. diff --git a/docs/simulator-interface.md b/docs/simulator-interface.md index e7aa440..be4f2df 100644 --- a/docs/simulator-interface.md +++ b/docs/simulator-interface.md @@ -80,9 +80,9 @@ Player events come from the scenario's `events` and the modeled `Damage`/`Heal` ### General and array values -`If-Then-Else`, `Compare` (all six operators), `Not`, `Or`, `Divide`, `Modulo`, `Min`, `Max`, `Raise To Power`, `Round To Integer`, `Sine From Radians`, `First Of`, `Last Of`, `Count Of`, `Empty Array`, `Array Contains`, `Index Of Array Value`, `Array Slice`, `Append To Array`, `Remove From Array`, `Value In Array` and `Number Of Players` are modeled, as are the `Set`/`Modify * Variable At Index` actions and the full `Modify` operator set (`Add` through `Remove From Array By Index`). Coercions follow the Workshop rules: numbers, booleans and `Null` read as numbers (`Null` and `False` are `0`), `Null` reads as the empty array, and an enum literal in a value position yields its catalog member id as text. `==`/`!=` compare arrays element-wise; different types are never equal. `Value In Array` yields the element at the index, or `Null` when none exists — an out-of-range or negative index, or a non-array operand (athena#97). A `Set * At Index` past the end appends; a negative index writes nothing. A `Modify * At Index` outside the array writes nothing. `Remove From Array` drops every element equal to the operand — an array operand, every element equal to any element it holds — and `Remove From Array By Value` drops every match. `And`/`Or`/`Not`/`If-Then-Else` read truthiness (`Null` and `0` are false) and `And`/`Or` short-circuit; `If-Then-Else` reads only the taken branch. +`If-Then-Else`, `Compare` (all six operators), `Not`, `Or`, `Divide`, `Modulo`, `Min`, `Max`, `Raise To Power`, `Round To Integer`, `Sine From Radians`, `First Of`, `Last Of`, `Count Of`, `Empty Array`, `Array Contains`, `Index Of Array Value`, `Array Slice`, `Append To Array`, `Remove From Array`, `Value In Array` and `Number Of Players` are modeled, as are the `Set`/`Modify * Variable At Index` actions and the full `Modify` operator set (`Add` through `Remove From Array By Index`). Coercions follow the Workshop rules: numbers, booleans and `Null` read as numbers (`Null` and `False` are `0`), `Null` reads as the empty array, and an enum literal in a value position yields its catalog member id as text. `==`/`!=` compare arrays element-wise; different types are never equal. `Value In Array` yields the indexed element, or `Null` when none exists — an out-of-range, negative or non-array operand (athena#97). A `Set * At Index` past the end appends; a negative index or a `Modify * At Index` outside the array writes nothing. `Remove From Array` drops every element equal to the operand — an array operand, every element equal to one it holds — and `Remove From Array By Value` drops every match. `And`/`Or`/`Not`/`If-Then-Else` read truthiness and `And`/`Or` short-circuit; `If-Then-Else` reads only the taken branch. A produced number is finite — a non-finite result stops `not_modeled` naming its construct. -The element-context values: `Evaluate Once` pins its operand's first read per site for the rest of one scope — a rule-instance run (loop bodies and restarted frames re-read the site; a `Wait` suspension keeps them; the next run start or restart clears them), one rule-condition check pass, or one `Wait Until` call's per-tick re-checks — and inside an element-context operand, re-read per element, it is `not_modeled`. `Filtered Array` keeps the elements its condition holds for, `Sorted Array` orders ascending by the per-element key — a number, `Null` or boolean under the number coercion, equal keys keeping place — `Is True For All`/`Is True For Any` test the condition per element, stopping at the deciding element — an empty array is true for `All`, false for `Any` — and `Mapped Array` collects the operand's per-element result, a non-finite one `not_modeled`. Each binds `Current Array Element` and `Current Array Index` to the element and its place, nested contexts included; a read outside one is `not_modeled`. +The element-context values: `Evaluate Once` pins its operand's first read per site for the rest of one scope — a rule-instance run (loop bodies and restarted frames re-read the site; a `Wait` suspension keeps them; the next run start or restart clears them), one rule-condition check pass, or one `Wait Until` call's per-tick re-checks — and inside an element-context operand, re-read per element, it is `not_modeled`. `Filtered Array` keeps the elements its condition holds for, `Sorted Array` orders ascending by the per-element key — a number, `Null` or boolean under the number coercion, equal keys keeping place — `Is True For All`/`Is True For Any` test the condition per element, stopping at the deciding element — an empty array is true for `All`, false for `Any` — and `Mapped Array` collects the operand's per-element result. Each binds `Current Array Element` and `Current Array Index` to the element and its place, nested contexts included; a read outside one is `not_modeled`. `All Heroes` yields the catalog's `Hero` members as member-id texts in catalog order — the form `Hero Of` and a `Hero` literal yield; the client's element order is unmeasured. diff --git a/docs/trace-format.md b/docs/trace-format.md index 8437d91..f53ebc9 100644 --- a/docs/trace-format.md +++ b/docs/trace-format.md @@ -128,6 +128,6 @@ Each `changes` element has `kind` plus its own fields; `old`/`new` serialize as } ``` -`outcome` is `{"status": "complete"}`, `{"status": "not_modeled", "construct": ..., "tick": ..., "location": ...}` or `{"status": "missing_input"}`. A value is `{"type": ..., "value": ...}` with `type` one of `null`, `bool`, `number`, `text`, `array`, `player`, `vector`, `display`; `null` has no `value`. `player` holds the scenario `id` that declared the player; `vector` holds `{ "x": n, "y": n, "z": n }`. `players` is a map keyed by the scenario player `id`; a row's `world` is `null` when the player's catalog state could not be resolved. +`outcome` is `{"status": "complete"}`, `{"status": "not_modeled", "construct": ..., "tick": ..., "location": ...}` or `{"status": "missing_input"}`. A value is `{"type": ..., "value": ...}` with `type` one of `null`, `bool`, `number`, `text`, `array`, `player`, `vector`, `display`; `null` has no `value`. `number` holds a finite `f64` — a computation that would produce `NaN` or an infinity stops the run `not_modeled` before the value can enter the trace. `player` holds the scenario `id` that declared the player; `vector` holds `{ "x": n, "y": n, "z": n }`. `players` is a map keyed by the scenario player `id`; a row's `world` is `null` when the player's catalog state could not be resolved. Adding a field is compatible. Removing or renaming one, or changing a value's meaning, is a breaking change and needs a version bump of this crate.