diff --git a/Cargo.lock b/Cargo.lock index 8beb817..2153f74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ dependencies = [ [[package]] name = "athena-sim" -version = "0.11.0" +version = "0.11.1" dependencies = [ "jsonschema", "serde", diff --git a/crates/athena-sim/src/actions.rs b/crates/athena-sim/src/actions.rs index c775c34..d629650 100644 --- a/crates/athena-sim/src/actions.rs +++ b/crates/athena-sim/src/actions.rs @@ -11,6 +11,7 @@ use workshop_rs::Value; use crate::eval::{button_slot, eval, number, player_id, player_ids}; use crate::load::Instance; +use crate::scenario::EventCtx; use crate::state; use crate::step::{World, world_state}; use crate::unsupported; @@ -75,26 +76,67 @@ fn run( let ids = player_ids(player, &env)?; let damager = eval(damager, &env)?; let amount = number(&eval(amount, &env)?)?; - let dealt = match damager { - Val::Null => 1.0, - Val::Player(id) => world_state(world, &id)?.damage_dealt, + let dealer = match &damager { + Val::Null => None, + Val::Player(id) => Some(world_state(world, id)?.damage_dealt), other => { return unsupported(format!("{other:?} where a player is needed")); } }; for id in ids { - let target = world_state(world, &id)?; - let before = pools(&target.world); - state::damage(&mut target.world, amount * dealt * target.damage_received); - let after = pools(&target.world); - for ((field, old), (_, new)) in before.into_iter().zip(after) { - if old != new { - world.changes.borrow_mut().push(Change::World { - player: id.clone(), - field: field.to_string(), - old: old.map_or(Val::Null, Val::Number), - new: new.map_or(Val::Null, Val::Number), - }); + // A dead target takes no pool change and raises no + // events — `isDead` decides dead, as `Is Alive` reads + // it, and the health pool decides where `isDead` + // cannot answer. + if dead(world, &id, "action damage")? { + continue; + } + let (applied, died) = { + let target = world_state(world, &id)?; + let applied = amount * dealer.unwrap_or(1.0) * target.damage_received; + let before = pools(&target.world); + let health = target.world.health.current; + state::damage(&mut target.world, applied); + let died = health > 0.0 && target.world.health.current == 0.0; + let after = pools(&target.world); + for ((field, old), (_, new)) in before.into_iter().zip(after) { + if old != new { + world.changes.borrow_mut().push(Change::World { + player: id.clone(), + field: field.to_string(), + old: old.map_or(Val::Null, Val::Number), + new: new.map_or(Val::Null, Val::Number), + }); + } + } + (applied, died) + }; + // The damage raises the player events the catalog + // defines for the cause, in occurrence order: the + // victim's `Player Took Damage`, the dealer's `Player + // Dealt Damage`, then — when the hit brought health to + // zero — the victim's `Player Died` and the dealer's + // `Player Dealt Final Blow`. A `Null` dealer raises no + // dealer-side event and binds `Attacker` to `Null`; a + // `Damage` on an already-dead target raises nothing. + if applied > 0.0 { + let values = event_values(&damager, &id, "eventDamage", applied); + raise(world, "playerTookDamage", &id, values.clone())?; + if let Val::Player(dealer) = &damager { + raise(world, "playerDealtDamage", dealer, values.clone())?; + } + if died { + crate::state_writes::write_cell( + world, + &id, + "isDead", + Val::Bool(true), + "action damage", + )?; + raise(world, "playerDied", &id, values.clone())?; + if let Val::Player(dealer) = &damager { + raise(world, "playerDealtFinalBlow", dealer, values)?; + } } } } @@ -104,30 +146,46 @@ fn run( return unsupported("heal arguments"); }; // Same shape as `damage`, with `healingDealt%` and - // `healingReceived%` scales. + // `healingReceived%` scales. A positive heal on a living + // player raises `Player Received Healing` for the healed + // and `Player Dealt Healing` for a player healer, in that + // order. let ids = player_ids(player, &env)?; let healer = eval(healer, &env)?; let amount = number(&eval(amount, &env)?)?; - let dealt = match healer { - Val::Null => 1.0, - Val::Player(id) => world_state(world, &id)?.healing_dealt, + let dealer = match &healer { + Val::Null => None, + Val::Player(id) => Some(world_state(world, id)?.healing_dealt), other => { return unsupported(format!("{other:?} where a player is needed")); } }; for id in ids { + // The same death gate as `Damage`: a dead target takes + // no pool change and raises no healing events. + if dead(world, &id, "action heal")? { + continue; + } let target = world_state(world, &id)?; + let applied = amount * dealer.unwrap_or(1.0) * target.healing_received; let old = target.world.health.current; - state::heal(&mut target.world, amount * dealt * target.healing_received); + state::heal(&mut target.world, applied); let new = target.world.health.current; if new != old { world.changes.borrow_mut().push(Change::World { - player: id, + player: id.clone(), field: "health".to_string(), old: Val::Number(old), new: Val::Number(new), }); } + if applied > 0.0 { + let values = event_values(&healer, &id, "eventHealing", applied); + raise(world, "playerReceivedHealing", &id, values.clone())?; + if let Val::Player(dealer) = &healer { + raise(world, "playerDealtHealing", dealer, values)?; + } + } } } "setAbilityCooldown" => { @@ -283,6 +341,18 @@ fn run( Ok(()) } +/// Whether `Damage`/`Heal` treats player `id` as dead: the `isDead` +/// read decides where it answers — a cell an action wrote or the +/// declared `state.isDead`, the same read `Is Alive` makes — and the +/// health pool decides where `isDead` cannot answer. +fn dead(world: &mut World, id: &str, construct: &str) -> Result { + let read = crate::state_reads::state_read("isDead").expect("in STATE_READS"); + match crate::player::state_read_value(&world.players, id, read, construct) { + Ok(Val::Bool(dead)) => Ok(dead), + _ => Ok(world_state(world, id)?.world.health.current <= 0.0), + } +} + /// 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] { @@ -292,3 +362,42 @@ fn pools(world: &PlayerWorld) -> [(&'static str, Option); 3] { ("health", Some(world.health.current)), ] } + +/// Queue a player event `cause` raised on `world.pending_events` for +/// the tick loop to deliver: `player` is the event player the rule's +/// `Event Player` binds. A raised event counts toward the tick's +/// [`crate::step::TICK_EVENT_LIMIT`]: a triggered rule that damages or +/// heals again can fan the queue out without bound without one. +fn raise( + world: &mut World, + kind: &str, + player: &str, + values: BTreeMap, +) -> Result<(), Stop> { + if world.tick_events >= crate::step::TICK_EVENT_LIMIT { + return unsupported(format!( + "more than {} player events raised in one tick", + crate::step::TICK_EVENT_LIMIT + )); + } + world.tick_events += 1; + world.pending_events.push(EventCtx { + kind: kind.to_string(), + player: Some(player.to_string()), + values, + raised: true, + }); + Ok(()) +} + +/// The `values` a `Damage`/`Heal` event binds: `Attacker` the dealing +/// or healing player (`Null` when the cause names none), `Victim` the +/// player the pool moved on, and `key` (`eventDamage`/`eventHealing`) +/// the applied amount. +fn event_values(cause: &Val, victim: &str, key: &str, amount: f64) -> BTreeMap { + BTreeMap::from([ + ("attacker".to_string(), cause.clone()), + ("victim".to_string(), Val::Player(victim.to_string())), + (key.to_string(), Val::Number(amount)), + ]) +} diff --git a/crates/athena-sim/src/call.rs b/crates/athena-sim/src/call.rs index a4fdade..5d160ff 100644 --- a/crates/athena-sim/src/call.rs +++ b/crates/athena-sim/src/call.rs @@ -20,15 +20,19 @@ fn missing( Err(MissingInput::new(input, player, name, construct).into()) } -/// Event-scoped values the simulator resolves from an injected event's -/// `values` map. Player-, enum- and vector-typed event values (`attacker`, -/// `victim`, `hostPlayer`, `eventAbility`, `eventDirection`) are not modeled. +/// Event-scoped values the simulator resolves from an event's `values` +/// map — the scenario `values` of an injected event, or the bindings a +/// modeled cause (`Damage`/`Heal`) attaches to the events it raises. +/// Enum- and vector-typed event values (`eventAbility`, `eventDirection`) +/// are not modeled. const EVENT_VALUES: &[&str] = &[ + "attacker", "eventDamage", "eventHealing", "eventWasCriticalHit", "eventWasEnvironment", "eventWasHealthPack", + "victim", ]; /// The truthiness a condition-family value (`and`, `or`, `not`, @@ -625,6 +629,14 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result { let [] = arguments::<0>(name, args)?; match env.event.and_then(|event| event.values.get(name)).cloned() { Some(value) => Ok(value), + // A raised event binds only what its cause attached; + // the scenario `values` an injected event could take + // have no path to it, so an unbound read is + // `not_modeled` — as `eventAbility` already is — + // never a missing input. + None if env.event.is_some_and(|event| event.raised) => { + unsupported(format!("value {name}")) + } None => missing( MissingInputKind::EventValue, None, diff --git a/crates/athena-sim/src/eval.rs b/crates/athena-sim/src/eval.rs index 2dd9c17..aaefe01 100644 --- a/crates/athena-sim/src/eval.rs +++ b/crates/athena-sim/src/eval.rs @@ -14,10 +14,10 @@ use crate::{Change, MissingInput, MissingInputKind, Stop, Val}; pub(crate) struct Env<'a> { pub globals: &'a BTreeMap, pub players: &'a BTreeMap, - /// The injected event the running rule was triggered by, if any. + /// The player event the running rule was triggered by, if any. pub event: Option<&'a EventCtx>, /// The player an `Ongoing - Each Player` instance runs for; `Event - /// Player` reads it when no injected event supplies one. + /// Player` reads it when no triggering event supplies one. pub each_player: Option<&'a str>, /// The display elements the run created; what `Last Text ID` and /// `Text Count` read. A `RefCell` so an `Env` — which borrows it — diff --git a/crates/athena-sim/src/events.rs b/crates/athena-sim/src/events.rs new file mode 100644 index 0000000..b35116b --- /dev/null +++ b/crates/athena-sim/src/events.rs @@ -0,0 +1,196 @@ +//! Player-event dispatch: the injected events a scenario schedules +//! and the events a modeled state change (`Damage`, `Heal`) raises. +//! Both reach an `Event::Player` rule only when the kind matches and +//! the event player satisfies the rule's team/player filters; an +//! instance that is not free queues the event and fires it when its +//! run is idle. + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use workshop_rs::{Event, EventTarget, EventTeam, Rule}; + +use crate::blocks::Blocks; +use crate::eval::Env; +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}; + +/// Fire the events `world.pending_events` holds — raised by a modeled +/// state change like `Damage`/`Heal` — in the order they occurred: each +/// event runs the `Event::Player` rules whose kind and team/player +/// filters match, in rule-file order. An instance that is not free — +/// suspended on `Wait`, mid-run, or still holding earlier events — +/// queues the event instead; its own visit's pending loop fires it +/// when the run is idle, like an injected event's. +pub(crate) fn dispatch( + instances: &mut [Instance<'_>], + rule_list: &[&Rule], + blocks: &[Blocks], + subroutines: &BTreeMap, + world: &mut World, + tick: u64, + now: f64, +) -> Result<(), Stop> { + for ctx in std::mem::take(&mut world.pending_events) { + for instance in instances.iter_mut() { + if !eligible(instance, world, &ctx, tick)?.unwrap_or(false) { + continue; + } + if instance.wake.is_none() && instance.frames.is_empty() && instance.pending.is_empty() + { + instance.event = Some(ctx.clone()); + fire(instance, rule_list, blocks, subroutines, world, tick, now)?; + } else { + instance.pending.push_back(ctx.clone()); + } + } + } + Ok(()) +} + +/// Queue `ctx` on every `Event::Player` instance whose event kind is +/// `ctx.kind` and whose team/player filters the event player satisfies: +/// an injected event's dispatch. +pub(crate) fn queue( + instances: &mut [Instance<'_>], + world: &mut World, + ctx: &EventCtx, + tick: u64, +) -> Result<(), Stop> { + for instance in instances.iter_mut() { + if eligible(instance, world, ctx, tick)?.unwrap_or(false) { + instance.pending.push_back(ctx.clone()); + } + } + Ok(()) +} + +/// Check the conditions of `instance`'s `Event::Player` rule against +/// the event bound on it, and run when they hold: the sequence an +/// event-triggered run follows, shared by the injected events' pending +/// loop and the immediate dispatch of the events a modeled state +/// change raises. +pub(crate) fn fire( + instance: &mut Instance<'_>, + rule_list: &[&Rule], + blocks: &[Blocks], + subroutines: &BTreeMap, + world: &mut World, + tick: u64, + now: f64, +) -> Result<(), Stop> { + // Each event's condition check is its own `Evaluate Once` pass. + let pass = RefCell::new(BTreeMap::new()); + let env = Env { + globals: &world.globals, + players: &world.players, + event: instance.event.as_ref(), + each_player: None, + hud: &world.hud, + element: None, + once: &pass, + effects: &world.effects, + random: world.random.as_ref(), + server_load: world.server_load, + map: world.map.as_deref(), + match_phase: world.match_phase, + game_mode: world.game_mode.as_deref(), + objectives: world.objectives.as_deref(), + rule: instance.rule_idx, + changes: &world.changes, + now, + match_time: world.clock.match_time, + }; + if conditions_hold(instance.rule, &env).map_err(|stop| { + record_stop( + &mut world.runs, + RuleRun { + rule: instance.rule_idx, + name: instance.rule.name.clone(), + event_player: event_player(instance), + ended: RunEnd::Stopped, + }, + None, + stop.at(tick), + ) + })? { + instance.once.get_mut().clear(); + instance.frames.push(Frame { + rule_idx: instance.rule_idx, + pc: 0, + }); + run_and_record(instance, rule_list, blocks, subroutines, world, tick, now) + .map_err(|e| e.at(tick))?; + } else { + instance.event = None; + } + Ok(()) +} + +/// Whether `instance`'s `Event::Player` rule fires for `ctx`: `None` +/// when its event kind does not match, otherwise the team/player +/// filters' verdict on the event player — `missing_input` when a +/// filtered rule cannot read one. +fn eligible( + instance: &Instance<'_>, + world: &mut World, + ctx: &EventCtx, + tick: u64, +) -> Result, Stop> { + let Event::Player { kind, team, target } = &instance.rule.event else { + return Ok(None); + }; + if kind.catalog_id() != ctx.kind { + return Ok(None); + } + // The team/player filters read the event player: an event that + // does not name one can only satisfy `All`. + let eligible = match &ctx.player { + Some(id) => player_matches( + *team, + target, + world + .players + .get(id) + .expect("an event player is a declared scenario player"), + ) + .map_err(|stop| { + record_stop( + &mut world.runs, + RuleRun { + rule: instance.rule_idx, + name: instance.rule.name.clone(), + event_player: ctx.player.clone(), + ended: RunEnd::Stopped, + }, + None, + stop.at(tick), + ) + })?, + None if *team == EventTeam::All && *target == EventTarget::All => true, + None => { + return Err(record_stop( + &mut world.runs, + RuleRun { + rule: instance.rule_idx, + name: instance.rule.name.clone(), + event_player: None, + ended: RunEnd::Stopped, + }, + None, + MissingInput::new( + MissingInputKind::EventPlayer, + None, + None, + format!("rule `{}`: event team or player filter", instance.rule.name), + ) + .at(tick) + .into(), + )); + } + }; + Ok(Some(eligible)) +} diff --git a/crates/athena-sim/src/lib.rs b/crates/athena-sim/src/lib.rs index 7c0cbbc..a566397 100644 --- a/crates/athena-sim/src/lib.rs +++ b/crates/athena-sim/src/lib.rs @@ -5,6 +5,7 @@ mod call; mod clock; mod effects; mod eval; +mod events; mod expect; mod geometry; mod hero_settings; @@ -135,7 +136,8 @@ pub enum MissingInputKind { Team, /// The `eventPlayer` of an injected event. EventPlayer, - /// An entry of an injected event's `values`. + /// An entry of an injected event's `values`; an event value a + /// raised event does not bind is `not_modeled`, not a missing input. EventValue, /// The held state of a player's button. Button, diff --git a/crates/athena-sim/src/load.rs b/crates/athena-sim/src/load.rs index d590c74..264c5ee 100644 --- a/crates/athena-sim/src/load.rs +++ b/crates/athena-sim/src/load.rs @@ -40,9 +40,10 @@ pub(crate) struct Instance<'a> { pub wake: Option, pub wait_mode: WaitMode, pub prev: bool, - /// Injected events that triggered this rule and wait for it to be idle. + /// Player events — injected or raised — that triggered this rule + /// and wait for it to be idle. pub pending: VecDeque, - /// The injected event the current run executes for, if event-triggered. + /// The player event the current run executes for, if event-triggered. pub event: Option, /// The clause head a false `If`/`Else If` jumped to, as a frame /// position. A head that is not this target was reached by fall-through diff --git a/crates/athena-sim/src/scenario.rs b/crates/athena-sim/src/scenario.rs index dd21739..744ad74 100644 --- a/crates/athena-sim/src/scenario.rs +++ b/crates/athena-sim/src/scenario.rs @@ -106,7 +106,8 @@ pub(crate) struct Player { pub world: crate::state::WorldSlot, } -/// The runtime context of an injected event while a rule it triggered runs. +/// The runtime context of a player event — injected by the scenario or +/// raised by a modeled state change — while a rule it triggered runs. #[derive(Debug, Clone, PartialEq)] pub(crate) struct EventCtx { /// The Workshop event name. @@ -115,6 +116,11 @@ pub(crate) struct EventCtx { pub player: Option, /// The event's `values` map. pub values: BTreeMap, + /// `true` when a modeled state change (`Damage`/`Heal`) raised the + /// event. Only an injected event takes scenario `values`, so an + /// event value a raised event does not bind is `not_modeled`, + /// never a missing input. + pub raised: bool, } /// An event the scenario injects. @@ -554,7 +560,28 @@ pub(crate) fn load(json: &str, catalog: &Catalog) -> Result { + if !players.contains_key(id) { + return load_error(format!( + "event `{}` value `{name}` names unknown player `{id}`", + event.kind + )); + } + Val::Player(id.clone()) + } + ("attacker" | "victim", serde_json::Value::Null) => Val::Null, + ("attacker" | "victim", _) => { + return load_error(format!( + "event `{}` value `{name}` must be a player `id` or `null`", + event.kind + )); + } + _ => value_to_val(value)?, + }; + values.insert(name.clone(), value); } events.push(InjectedEvent { kind: event.kind, diff --git a/crates/athena-sim/src/sched.rs b/crates/athena-sim/src/sched.rs index 27e5970..4f07797 100644 --- a/crates/athena-sim/src/sched.rs +++ b/crates/athena-sim/src/sched.rs @@ -2,21 +2,22 @@ use std::cell::RefCell; use std::collections::BTreeMap; use workshop_rs::hero_values::HeroValues; -use workshop_rs::{Action, Event, EventTarget, EventTeam, Program, Rule}; +use workshop_rs::{Action, Event, Program, Rule}; use crate::blocks::Blocks; use crate::call::Random; use crate::eval::{Env, eval}; +use crate::events; use crate::expect::Expectation; use crate::hud::Hud; -use crate::load::{Frame, Instance, Loaded, WaitMode, load, player_matches}; +use crate::load::{Frame, Instance, Loaded, WaitMode, load}; use crate::scenario::{EventCtx, Scenario}; use crate::state::{self, WorldSlot}; use crate::step::{World, conditions_hold, flag, run_instance}; use crate::{ - ExpectationResult, ExpectationStatus, MissingInput, MissingInputKind, NotModeled, Options, - Outcome, PlayerState, RuleRun, RunEnd, Stop, StopIndex, StopLocation, TIMELINE_CHANGE_LIMIT, - TIMELINE_ENTRY_LIMIT, TIMELINE_RULE_LIMIT, Timeline, TimelineEntry, Trace, Val, + ExpectationResult, ExpectationStatus, NotModeled, Options, Outcome, PlayerState, RuleRun, + RunEnd, Stop, StopIndex, StopLocation, TIMELINE_CHANGE_LIMIT, TIMELINE_ENTRY_LIMIT, + TIMELINE_RULE_LIMIT, Timeline, TimelineEntry, Trace, Val, }; /// One server tick (P0, P1). @@ -87,6 +88,8 @@ pub(crate) fn run( hud: RefCell::new(Hud::default()), effects: RefCell::new(crate::effects::Effects::default()), pending_starts: Vec::new(), + pending_events: Vec::new(), + tick_events: 0, runs: Vec::new(), changes: RefCell::new(Vec::new()), random: random.map(|seed| RefCell::new(Random::new(seed))), @@ -211,9 +214,9 @@ fn expectations( .collect() } -/// The run's `Event Player`: an injected event's `eventPlayer`, or the +/// The run's `Event Player`: the triggering event's player, or the /// player an `Ongoing - Each Player` instance is bound to. -fn event_player(inst: &Instance<'_>) -> Option { +pub(crate) fn event_player(inst: &Instance<'_>) -> Option { inst.event .as_ref() .and_then(|event| event.player.clone()) @@ -223,7 +226,7 @@ fn event_player(inst: &Instance<'_>) -> Option { /// Run one instance and record the execution in `world.runs`: a /// `Wait`/`Wait Until` reports `waiting` with its wake tick, a stop /// reports `stopped` and gains the failing action's location. -fn run_and_record( +pub(crate) fn run_and_record( inst: &mut Instance<'_>, rule_list: &[&Rule], blocks: &[Blocks], @@ -274,7 +277,12 @@ fn run_and_record( /// check or `Wait Until` re-check — still ends a rule's work on the tick: /// the tick's `rules` list shows the rule `stopped`, and the stop gains /// the same location. -fn record_stop(runs: &mut Vec, run: RuleRun, at: Option, stop: Stop) -> Stop { +pub(crate) fn record_stop( + runs: &mut Vec, + run: RuleRun, + at: Option, + stop: Stop, +) -> Stop { let location = StopLocation { rule: run.rule, name: run.name.clone(), @@ -389,13 +397,14 @@ fn tick_loop( Ok(()) } -/// One tick: fire the injected events due on it, then run every instance -/// the tick starts or resumes. Rule executions accumulate in `world.runs`, -/// the state changes they make in `world.changes`. +/// One tick: deliver the raised events pending on it, queue the injected +/// events due on it, then run every instance the tick starts or resumes. +/// Rule executions accumulate in `world.runs`, the state changes they +/// make in `world.changes`. fn run_tick( loaded: &mut Loaded<'_>, world: &mut World, - events: &BTreeMap>, + by_tick: &BTreeMap>, tick: u64, now: f64, ) -> Result<(), Stop> { @@ -405,70 +414,20 @@ fn run_tick( subroutines, instances, } = loaded; - if let Some(due) = events.get(&tick) { + world.tick_events = 0; + // Events a modeled state change queued on an earlier tick come + // first: they happened before this tick's injected events. + events::dispatch(instances, rule_list, blocks, subroutines, world, tick, now)?; + if let Some(due) = by_tick.get(&tick) { for injected in due { - for instance in instances.iter_mut() { - if let Event::Player { kind, team, target } = &instance.rule.event - && kind.catalog_id() == injected.kind - { - // The team/player filters read the event player: an - // event that does not name one can only satisfy - // `All`. - let eligible = match &injected.player { - Some(id) => player_matches( - *team, - target, - world - .players - .get(id) - .expect("the scenario loader checks `eventPlayer` is declared"), - ) - .map_err(|stop| { - record_stop( - &mut world.runs, - RuleRun { - rule: instance.rule_idx, - name: instance.rule.name.clone(), - event_player: injected.player.clone(), - ended: RunEnd::Stopped, - }, - None, - stop.at(tick), - ) - })?, - None if *team == EventTeam::All && *target == EventTarget::All => true, - None => { - return Err(record_stop( - &mut world.runs, - RuleRun { - rule: instance.rule_idx, - name: instance.rule.name.clone(), - event_player: None, - ended: RunEnd::Stopped, - }, - None, - MissingInput::new( - MissingInputKind::EventPlayer, - None, - None, - format!( - "rule `{}`: event team or player filter", - instance.rule.name - ), - ) - .at(tick) - .into(), - )); - } - }; - if eligible { - instance.pending.push_back(injected.clone()); - } - } - } + events::queue(instances, world, injected, tick)?; } } - for instance in instances.iter_mut() { + for index in 0..instances.len() { + // `Damage`/`Heal` in the previous instance's run queued events; + // they fire between visits, in the order they occurred. + events::dispatch(instances, rule_list, blocks, subroutines, world, tick, now)?; + let instance = &mut instances[index]; // `Start Rule` requests targeting this instance's rule apply when // the instance is visited: a rule listed later in the program // starts on the same tick, an earlier one on the next tick. @@ -638,52 +597,7 @@ fn run_tick( break; }; instance.event = Some(ctx); - // Each pending event's condition check is its own - // `Evaluate Once` pass. - let pass = RefCell::new(BTreeMap::new()); - let env = Env { - globals: &world.globals, - players: &world.players, - event: instance.event.as_ref(), - each_player: None, - hud: &world.hud, - element: None, - once: &pass, - effects: &world.effects, - random: world.random.as_ref(), - server_load: world.server_load, - map: world.map.as_deref(), - match_phase: world.match_phase, - game_mode: world.game_mode.as_deref(), - objectives: world.objectives.as_deref(), - rule: instance.rule_idx, - changes: &world.changes, - now, - match_time: world.clock.match_time, - }; - if conditions_hold(instance.rule, &env).map_err(|stop| { - record_stop( - &mut world.runs, - RuleRun { - rule: instance.rule_idx, - name: instance.rule.name.clone(), - event_player: event_player(instance), - ended: RunEnd::Stopped, - }, - None, - stop.at(tick), - ) - })? { - instance.once.get_mut().clear(); - instance.frames.push(Frame { - rule_idx: instance.rule_idx, - pc: 0, - }); - run_and_record(instance, rule_list, blocks, subroutines, world, tick, now) - .map_err(|e| e.at(tick))?; - } else { - instance.event = None; - } + events::fire(instance, rule_list, blocks, subroutines, world, tick, now)?; } } Event::Subroutine(_) => { @@ -757,6 +671,7 @@ fn events_by_tick(events: &[crate::scenario::InjectedEvent]) -> BTreeMap { @@ -328,7 +348,7 @@ fn read_key(world: &World, id: &str, key: &str, construct: &str) -> Result, + /// Player events a modeled state change (`Damage`, `Heal`) raised; + /// the tick loop delivers them to the instances whose event kind and + /// team/player filters match, in queue order. + pub pending_events: Vec, + /// Player events raised on the tick in progress; `run_tick` resets + /// it, [`TICK_EVENT_LIMIT`] bounds a self-triggering chain. + pub tick_events: u64, /// The rule executions of the tick in progress — started, resumed or /// ended — in execution order; `tick_loop` drains them into the /// tick's timeline entry. diff --git a/crates/athena-sim/src/timeline.rs b/crates/athena-sim/src/timeline.rs index a8bd425..ad76e4e 100644 --- a/crates/athena-sim/src/timeline.rs +++ b/crates/athena-sim/src/timeline.rs @@ -13,8 +13,8 @@ pub struct RuleRun { pub rule: usize, /// The rule's name. pub name: String, - /// The scenario `id` of the run's `Event Player`: an injected event's - /// `eventPlayer`, or the player an `Ongoing - Each Player` run is + /// The scenario `id` of the run's `Event Player`: the triggering + /// event's player, or the player an `Ongoing - Each Player` run is /// bound to; `null` for `Ongoing - Global` runs and events without /// one. pub event_player: Option, diff --git a/crates/athena-sim/tests/hero_settings.rs b/crates/athena-sim/tests/hero_settings.rs index 2ad6273..adcd152 100644 --- a/crates/athena-sim/tests/hero_settings.rs +++ b/crates/athena-sim/tests/hero_settings.rs @@ -49,7 +49,7 @@ settings {{ rule ("act") {{ event {{ - Player Dealt Damage; + Player Joined Match; All; All; }} @@ -61,12 +61,12 @@ rule ("act") {{ ) } -/// A scenario with the given players and one damage event on `event_player`. +/// A scenario with the given players and one join event on `event_player`. fn hero_settings_scenario(players: &str, event_player: &str) -> String { format!( r#"{{ "scenarioVersion": 1, "seconds": 1, "players": [{players}], - "events": [{{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "{event_player}" }}] }}"# + "events": [{{ "kind": "playerJoined", "time": 0, "eventPlayer": "{event_player}" }}] }}"# ) } @@ -76,7 +76,7 @@ fn hero_settings_scenario_v2(players: &str, event_player: &str) -> String { format!( r#"{{ "scenarioVersion": 2, "seconds": 1, "players": [{players}], - "events": [{{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "{event_player}" }}] }}"# + "events": [{{ "kind": "playerJoined", "time": 0, "eventPlayer": "{event_player}" }}] }}"# ) } diff --git a/crates/athena-sim/tests/player_events.rs b/crates/athena-sim/tests/player_events.rs new file mode 100644 index 0000000..c4135b7 --- /dev/null +++ b/crates/athena-sim/tests/player_events.rs @@ -0,0 +1,552 @@ +//! Player events: scenario `events` trigger the matching rules, and the +//! modeled `Damage`/`Heal` state changes raise the player events the +//! catalog defines for the cause (athena#47). + +use athena_sim::{ExpectationStatus, Outcome, Val, run_scenario}; + +fn run(text: &str, scenario: &str) -> athena_sim::Trace { + run_scenario(text, scenario).unwrap_or_else(|error| panic!("{error}")) +} + +/// A `Player Left Match` rule counting its fires and marking the player. +const LEFT: &str = r#" +variables { + global: + 0: hits + player: + 0: left +} + +rule ("left") { + event { + Player Left Match; + All; + All; + } + actions { + Modify Global Variable(hits, Add, 1); + Set Player Variable(Event Player, left, true); + } +} +"#; + +#[test] +fn a_scenario_player_left_event_runs_the_matching_rule_once() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 2, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ], + "events": [{ "kind": "playerLeft", "time": 0.5, "eventPlayer": "p1" }], + "expect": [ + { "kind": "playerVariable", "player": "p1", "name": "left", "value": true }, + { "kind": "playerVariable", "player": "p2", "name": "left", "value": null } + ] + }"#; + let trace = run(LEFT, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + assert_eq!(trace.globals["hits"], Val::Number(1.0)); + assert!( + trace + .expectations + .iter() + .all(|result| result.status == ExpectationStatus::Pass), + "{:?}", + trace.expectations + ); + // The rule ran exactly once, for the leaving player. + let fires: Vec<_> = trace + .timeline + .entries + .iter() + .flat_map(|entry| entry.rules.iter()) + .filter(|run| run.name == "left") + .collect(); + assert_eq!(fires.len(), 1); + assert_eq!(fires[0].event_player.as_deref(), Some("p1")); +} + +/// The four damage events record their firing order; the rules are +/// declared out of the occurrence order on purpose — an event reaches +/// its rules when it occurs, not when the tick loop reaches the rule. +const KILL_ORDER: &str = r#" +variables { + global: + 0: seq +} + +rule ("hit") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 2), 0), Value In Array(All Players(Team 1), 0), 9999); + } +} + +rule ("blow") { + event { + Player Dealt Final Blow; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 4); + } +} + +rule ("took") { + event { + Player Took Damage; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 1); + } +} + +rule ("died") { + event { + Player Died; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 3); + } +} + +rule ("dealt") { + event { + Player Dealt Damage; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 2); + } +} +"#; + +#[test] +fn a_killing_damage_runs_died_then_final_blow_in_occurrence_order() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(KILL_ORDER, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + // Took Damage, Dealt Damage, Died, Dealt Final Blow: the occurrence + // order, even though the rules are filed in another order. + assert_eq!( + trace.globals["seq"], + Val::Array(vec![ + Val::Number(1.0), + Val::Number(2.0), + Val::Number(3.0), + Val::Number(4.0) + ]) + ); + // The kill wrote the victim's `isDead` cell. + assert_eq!(trace.players["p2"].state["isDead"], Val::Bool(true)); +} + +/// The damage events' bindings: `Event Player`, `Attacker`, `Victim` +/// and `Event Damage`. +const BINDINGS: &str = r#" +variables { + global: + 0: ep + 1: dmg + 2: atk + 3: vic + 4: atkNull +} + +rule ("hit") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 2), 0), Value In Array(All Players(Team 1), 0), 30); + Damage(Value In Array(All Players(Team 1), 0), Null, 40); + } +} + +rule ("dealt") { + event { + Player Dealt Damage; + All; + All; + } + actions { + Set Global Variable(ep, Event Player); + Set Global Variable(dmg, Event Damage); + Set Global Variable(atk, Hero Of(Attacker)); + Set Global Variable(vic, Hero Of(Victim)); + } +} + +rule ("took") { + event { + Player Took Damage; + Team 1; + All; + } + actions { + Set Global Variable(atkNull, Compare(Attacker, ==, Null)); + } +} +"#; + +#[test] +fn damage_events_bind_event_player_attacker_victim_and_event_damage() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(BINDINGS, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + // `Player Dealt Damage` runs for the dealer: `Attacker` is the + // dealer, `Victim` the player the pool moved on, `Event Damage` + // the applied amount. + assert_eq!(trace.globals["ep"], Val::Player("p1".to_string())); + assert_eq!(trace.globals["dmg"], Val::Number(30.0)); + assert_eq!(trace.globals["atk"], Val::Text("ANA".to_string())); + assert_eq!(trace.globals["vic"], Val::Text("REAPER".to_string())); + // A `Null` dealer raises `Player Took Damage` with `Attacker` + // bound to `Null` — and no `Player Dealt Damage` at all (the dealt + // rule ran once, for the 30-damage hit). + assert_eq!(trace.globals["atkNull"], Val::Bool(true)); +} + +/// `Player Died` filtered to `Team 2`; the killed player is on Team 1. +const FILTERED_DIED: &str = r#" +variables { + global: + 0: hits +} + +rule ("hit") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 1), 0), Null, 9999); + } +} + +rule ("died") { + event { + Player Died; + Team 2; + All; + } + actions { + Modify Global Variable(hits, Add, 1); + } +} +"#; + +#[test] +fn a_damage_event_filtered_away_does_not_run() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [{ "id": "p1", "hero": "ana", "team": "team1" }] + }"#; + let trace = run(FILTERED_DIED, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + assert_eq!(trace.globals["hits"], Val::Null); +} + +/// The heal events a `Heal` raises: `Player Received Healing` for the +/// healed, `Player Dealt Healing` for a player healer, `Event Healing` +/// bound to the applied amount. +const HEAL_EVENTS: &str = r#" +variables { + global: + 0: seq + 1: healing +} + +rule ("soften") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 2), 0), Null, 100); + Heal(Value In Array(All Players(Team 2), 0), Value In Array(All Players(Team 1), 0), 40); + } +} + +rule ("dealtHealing") { + event { + Player Dealt Healing; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 2); + Set Global Variable(healing, Event Healing); + } +} + +rule ("receivedHealing") { + event { + Player Received Healing; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 1); + } +} +"#; + +#[test] +fn a_heal_raises_received_then_dealt_healing() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(HEAL_EVENTS, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + // Received then dealt, even though the dealt rule is filed first. + assert_eq!( + trace.globals["seq"], + Val::Array(vec![Val::Number(1.0), Val::Number(2.0)]) + ); + assert_eq!(trace.globals["healing"], Val::Number(40.0)); +} + +/// `Kill` writes the `isDead` marker the `Damage`/`Heal` gate reads: a +/// dead target takes no pool change and raises no events, and a +/// `Resurrect`ed target takes them again. +const DEAD_GATE: &str = r#" +variables { + global: + 0: seq +} + +rule ("cause") { + event { + Ongoing - Global; + } + actions { + Kill(Value In Array(All Players(Team 2), 0), Null); + Damage(Value In Array(All Players(Team 2), 0), Null, 9999); + Heal(Value In Array(All Players(Team 2), 0), Null, 30); + Resurrect(Value In Array(All Players(Team 2), 0)); + Damage(Value In Array(All Players(Team 2), 0), Null, 60); + Heal(Value In Array(All Players(Team 2), 0), Null, 30); + } +} + +rule ("took") { + event { + Player Took Damage; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 1); + } +} + +rule ("died") { + event { + Player Died; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 3); + } +} + +rule ("healed") { + event { + Player Received Healing; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 5); + } +} +"#; + +#[test] +fn a_dead_target_takes_no_pool_change_or_events_and_a_revived_one_does() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(DEAD_GATE, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + // The `Kill`-gated `Damage` and `Heal` raised nothing — no second + // `Player Died` either; the post-`Resurrect` actions raised their + // events once each. + assert_eq!( + trace.globals["seq"], + Val::Array(vec![Val::Number(1.0), Val::Number(5.0)]) + ); + // The gated actions never touched the pool: max − 60 + 30. + let world = trace.players["p2"].world.as_ref().unwrap(); + assert_eq!(world.health.current, world.health.max - 30.0); +} + +/// A `Damage` kill leaves the health pool at zero where `Kill` does +/// not: `Resurrect` and `Respawn` refill it, so a revived player +/// takes damage and dies — raising `Player Died` — again. +const DAMAGE_REVIVE: &str = r#" +variables { + global: + 0: seq +} + +rule ("cause") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 2), 0), Null, 9999); + Resurrect(Value In Array(All Players(Team 2), 0)); + Damage(Value In Array(All Players(Team 2), 0), Null, 9999); + Respawn(Value In Array(All Players(Team 2), 0)); + Damage(Value In Array(All Players(Team 2), 0), Null, 9999); + Damage(Value In Array(All Players(Team 2), 0), Null, 9999); + Heal(Value In Array(All Players(Team 2), 0), Null, 30); + } +} + +rule ("took") { + event { + Player Took Damage; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 1); + } +} + +rule ("died") { + event { + Player Died; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 3); + } +} + +rule ("healed") { + event { + Player Received Healing; + All; + All; + } + actions { + Modify Global Variable(seq, Append To Array, 5); + } +} +"#; + +#[test] +fn a_damage_killed_then_revived_player_dies_again() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(DAMAGE_REVIVE, scenario); + assert_eq!(trace.outcome, Outcome::Complete); + // `Player Died` fires once per revive-and-kill cycle — after the + // `Resurrect` and after the `Respawn`; the last kill leaves the + // target dead, so the trailing `Damage` and `Heal` raise nothing. + assert_eq!( + trace.globals["seq"], + Val::Array(vec![ + Val::Number(1.0), + Val::Number(3.0), + Val::Number(1.0), + Val::Number(3.0), + Val::Number(1.0), + Val::Number(3.0) + ]) + ); + assert_eq!(trace.players["p2"].state["isDead"], Val::Bool(true)); + let world = trace.players["p2"].world.as_ref().unwrap(); + assert_eq!(world.health.current, 0.0); +} + +/// `Event Was Critical Hit` has no binding on a `Damage`-raised event +/// and no scenario `values` path — an unbound read is `not_modeled`. +const CRITICAL: &str = r#" +variables { + global: + 0: crit +} + +rule ("hit") { + event { + Ongoing - Global; + } + actions { + Damage(Value In Array(All Players(Team 2), 0), Null, 30); + } +} + +rule ("took") { + event { + Player Took Damage; + All; + All; + } + actions { + Set Global Variable(crit, Event Was Critical Hit); + } +} +"#; + +#[test] +fn an_event_value_a_raised_event_does_not_bind_is_not_modeled() { + let scenario = r#"{ + "scenarioVersion": 1, + "seconds": 1, + "players": [ + { "id": "p1", "hero": "ana", "team": "team1" }, + { "id": "p2", "hero": "reaper", "team": "team2" } + ] + }"#; + let trace = run(CRITICAL, scenario); + let Outcome::NotModeled(not_modeled) = &trace.outcome else { + panic!("expected NotModeled, got {:?}", trace.outcome); + }; + assert_eq!(not_modeled.construct, "value eventWasCriticalHit"); +} diff --git a/crates/athena-sim/tests/scenario.rs b/crates/athena-sim/tests/scenario.rs index 3f1ce60..43e45c5 100644 --- a/crates/athena-sim/tests/scenario.rs +++ b/crates/athena-sim/tests/scenario.rs @@ -526,6 +526,17 @@ fn the_loader_checks_what_the_schema_cannot() { ), ScenarioError::Load(_) )); + // An `attacker`/`victim` event value is a player `id` or `null`. + for value in ["5", "true", "[1]", "{ \"x\": 1 }"] { + assert!(matches!( + scenario_error(&format!( + r#"{{ "scenarioVersion": 1, "seconds": 1, "players": [{{ "id": "p1" }}], + "events": [{{ "kind": "playerTookDamage", "time": 0, "eventPlayer": "p1", + "values": {{ "attacker": {value} }} }}] }}"# + )), + ScenarioError::Load(_) + )); + } // A hero `workshop-rs` does not know. assert!(matches!( scenario_error( @@ -591,7 +602,7 @@ fn test_values() -> workshop_rs::hero_values::HeroValues { const DAMAGE_HEAL: &str = r#" rule ("hit") { event { - Player Dealt Damage; + Player Joined Match; All; All; } @@ -613,7 +624,7 @@ fn damage_consumes_pools_in_shield_armor_health_order_and_heal_caps() { { "id": "p1", "hero": "Ana", "team": "team1" }, { "id": "p2", "hero": "Ashe", "team": "team2" } ], - "events": [{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(DAMAGE_HEAL, scenario, &values) @@ -654,7 +665,7 @@ fn heal_does_not_exceed_the_pool_maximum() { let text = r#" rule ("heal") { event { - Player Dealt Healing; + Player Joined Match; All; All; } @@ -669,7 +680,7 @@ rule ("heal") { "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1", "hero": "ana" }], - "events": [{ "kind": "playerDealtHealing", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(text, scenario, &values) @@ -690,7 +701,7 @@ fn a_hero_without_catalog_values_is_not_modeled() { "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1", "hero": "baptiste" }], - "events": [{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(DAMAGE_HEAL, scenario, &values) @@ -717,7 +728,7 @@ fn a_hero_without_an_ultimate_cost_is_not_modeled() { "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1", "hero": "D.Va" }], - "events": [{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(DAMAGE_HEAL, scenario, &values) @@ -739,7 +750,7 @@ fn damage_on_a_player_without_a_hero_is_not_modeled() { "scenarioVersion": 1, "seconds": 1, "players": [{ "id": "p1", "team": "team1" }], - "events": [{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(DAMAGE_HEAL, scenario, &values) @@ -761,7 +772,7 @@ fn the_trace_json_carries_the_player_table() { { "id": "p1", "hero": "Ana", "team": "team1" }, { "id": "p2" } ], - "events": [{ "kind": "playerDealtDamage", "time": 0, "eventPlayer": "p1" }] + "events": [{ "kind": "playerJoined", "time": 0, "eventPlayer": "p1" }] }"#; let values = test_values(); let trace = run_scenario_with_hero_values(DAMAGE_HEAL, scenario, &values) diff --git a/docs/architecture.md b/docs/architecture.md index b03ff7d..44a39a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,20 +59,21 @@ The simulator makes these choices only to stay deterministic; each needs a probe - A rule's condition is tracked every tick, also while its run is suspended; an edge during a Wait is dropped (P2). A rising edge on the exact tick a Wait ends is treated as dropped too. - Rules run in file order within a tick; several runs waking on the same tick resume in file order. -- An injected scenario event fires on tick `floor(time / 16 ms)`. Event-triggered rules run in file order interleaved with that tick's other rules; several events for one rule queue in file order. +- An injected scenario event fires on tick `floor(time / 16 ms)`. Event-triggered rules run in file order interleaved with that tick's other rules; several events for one rule queue in file order. Events a modeled cause raises (`Damage`/`Heal`, below) dispatch in occurrence order between rule-instance visits — a rule earlier in file order than the raiser still runs on the same tick — and queue on a busy or waiting instance like an injected event. Raised events bound at 1024 per tick: a triggered rule whose actions raise the same kind again would fan the queue out without bound; past the bound the run stops `not_modeled`. - A variable that is not set reads as `Null`, including a variable that a scenario does not declare ([scenario-format.md](scenario-format.md)). - Comparing values of different types with `==` gives false; `Null` equals `0`. Two mocked players compare by scenario `id`. - A mocked player is a scenario `id`; `Event Player` yields `{"type":"player"}`. `Hero Of` yields the catalog member id the declared `hero` resolves to; `Team Of` likewise yields the `Team` member id of the declared `team`, so it compares equal to a `Team` literal. -- An event rule fires when the injected event's `kind` matches the rule event's catalog id and the event player satisfies the rule's `team` and player (`target`) filters. +- An event rule fires when an event's `kind` matches the rule event's catalog id and the event player satisfies the rule's `team` and player (`target`) filters — for injected and raised events alike. - **Modeled, not measured** — no client capture backs the player state table or the `Damage`/`Heal` rules: - Each scenario player has a state-table row built at run start from the `workshop-rs` hero-values file pinned at `CURRENT_VERSION`: a `health` pool, `shield` and `armor` pools where the catalog records them (a hero without the pool has no entry, never a zero), the cooldown seconds of the ability slots the catalog records (`slot` or `slot/variant` keys), and an ultimate charge of `0` toward the catalog's `ultimateCost`. Pools start at their maximum. The program's hero settings then apply: `health%` scales every pool, the `abilityCooldown%` and per-slot `*Cooldown%` scalars scale the reported cooldowns, and the dealt/received scalars feed `Damage`/`Heal` below. Percent scalars authored at several scopes multiply together. A hero the file does not supply, a player with no `hero`, or a hero setting authored for the player that the simulator does not apply (one that moves a reported value — ultimate generation, spawning with ultimate, passive health regeneration or the role passive, headshots-only, spawning without mech, or a cooldown scalar with no reported slot) leaves the row's `world` null; an action that touches that world state (`Damage`, `Heal`, dealt or received) stops the run as `not_modeled`. The exception is a player whose version-2 `state` map declares every `state` key the setting can move — `ultCharge` for the ultimate-charge settings (`ultGen%`, `passiveUltGen%`, `combatUltGen%`, `enableSpawningWithUlt`), `health`, `maxHealth` and `normalizedHealth` for the health ones (`passiveHealthRegen`, `enableRolePassive`): the scenario already supplies the value the setting could move, so the setting is skipped — `state.` stands, the setting itself is not simulated (athena#91). The other unapplied settings have no `state` coverage and keep failing, and an opaque `Raw*` member fails the same way. - `Health`/`Max Health` read the pools' summed `current`/`max` only while the player declares no version-2 `state` map — a declared `state.health`/`state.maxHealth` answers instead (athena#89); `Ability Cooldown` reads the slot's reported seconds (`0` where the player has no such slot); `Set Ability Cooldown` writes the slot's reported seconds literally — whether `abilityCooldown%` also scales a scripted set is what probe P7 is for. Reading a player's world state is a touch like `Damage`/`Heal`. - `Damage(player, damager, amount)` takes `amount` off the pools in shield, armor, health order: each pool absorbs up to its `current` and the rest spills into the next; health floors at zero and a negative amount does nothing. `Heal(player, healer, amount)` raises the `health` pool by `amount`, capped at `max` — it never exceeds the maximum, never refills shield or armor, and a negative amount does nothing. Both accept a `Player` or an array of players. The amount lands scaled: by the dealer's `damageDealt%` and the target's `damageReceived%` for `Damage`, and by the healer's `healingDealt%` and the target's `healingReceived%` for `Heal`; a `Null` dealer or healer carries no scale. A `damager`/`healer` value that is not a player or `Null` is not modeled. + - Both raise the player events their effect implies (athena#47): a positive `Damage` on a target that was alive raises `Player Took Damage` on the target then `Player Dealt Damage` on a player dealer; a hit that brings health to zero writes `isDead` (like `Kill`) and raises `Player Died` on the target then `Player Dealt Final Blow` on a player dealer; a positive `Heal` on a living target raises `Player Received Healing` on the target then `Player Dealt Healing` on a player healer. Living or dead is the `isDead` read where it answers — a cell an action wrote or the declared `state.isDead`, as `Is Alive` reads it — and the health pool where `isDead` cannot answer; a `Damage` on an already-dead target raises nothing. Each raised event binds `Attacker` to the dealing or healing player (`Null` for none), `Victim` to the target, and `Event Damage`/`Event Healing` to the applied amount — the same bindings on every event of one cause. - **Modeled, not measured** — spawn state and the host player. No capture covers them: - `Has Spawned` of a scenario player with a `hero` is true from tick 0; `Is Alive` answers the `isDead` read — a state cell an action wrote or the declared `state.isDead` — when one answers, and the same hero model otherwise (the catalog names the two reads complements). Other respawn and death dynamics are not modeled. A player without a `hero` stops `Has Spawned` — and `Is Alive` when `isDead` cannot answer — as `not_modeled`. - `Host Player` returns the scenario player marked `"host": true`, a rule the project owner decided; the first player is not an implicit host. Without a marked player the run stops as `not_modeled`; two marked players fail the load. - `Wait Until(condition, timeout)` re-checks the condition on every tick after the one it starts on, and continues on the first tick it holds; P4 measures only constant conditions. `Wait Until(false, 0)` is `not_modeled`. - - `Abort When False` and `Restart When True` apply only to ongoing rules (`Ongoing - Global` and `Ongoing - Each Player` instances); `Wait Until` also re-checks inside a started subroutine (see `Start Rule` below). In a rule triggered by an injected event the three stop the run as `not_modeled`. A restart reruns the rule from its first action (P6: `nRestart` counts both starts). When a wake and a behavior trigger fall on one tick, the wake wins. + - `Abort When False` and `Restart When True` apply only to ongoing rules (`Ongoing - Global` and `Ongoing - Each Player` instances); `Wait Until` also re-checks inside a started subroutine (see `Start Rule` below). In a rule triggered by an event — injected or raised — the three stop the run as `not_modeled`. A restart reruns the rule from its first action (P6: `nRestart` counts both starts). When a wake and a behavior trigger fall on one tick, the wake wins. - **Modeled, not measured** — `For Global Variable`/`For Player Variable` execution; no capture covers the loop: - A `For` evaluates `start`, `stop` and `step`, writes the control variable with `start`, and exits before the body when the variable already reached or passed `stop` — "passed" follows `step`'s sign (a non-negative step exits at `>=`, a negative one at `<=`). Its `End` evaluates `stop` and `step`, adds `step` to the variable, and re-checks; a continuing loop jumps to the action after the `For`. A `step` of `0` on a range the start has not passed cannot terminate and ends at the per-run step limit instead. - **Modeled, not measured** — the control-flow actions (athena#58); no capture covers them: diff --git a/docs/findings.md b/docs/findings.md index fee118a..41783a4 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -56,6 +56,7 @@ Simulator choices no capture backs yet — the detail lives in [architecture.md] - `Is Duplicating` (athena#111): the duplication flag is a per-player fact no Bastion action can change, so it joins the version-2 `state` map as the `isDuplicatingAHero` key — scenario-supplied, not simulated; the catalog's "Whether the specified player is duplicating another hero" names no other state the model carries. - `Null` in a vector position (athena#108): an unset variable reads `Null`, and a `Null` where a `Vector` operand is expected is the zero vector — the same default-zero read `number()` makes of `Null` in a number position (`Null` and `False` are `0`). Basis: the model's other default-zero reads (`==`, arithmetic operands, array positions reading `Null` as the empty array), the catalog's `zeroAsNull`/`nullVectorAsNull` literal-substitution facts that make `0`, `Null` and `Vector(0, 0, 0)` interchangeable, and `Teleport`'s `Vector` position already reading an evaluated `Null` as the origin. The rule covers every strict `Vector` position the model answers — the component accessors, the products, `Normalize`/`Magnitude`, the angle-from-direction values, the `Local`/`World Vector Of` vector argument, the position reads (`Distance Between`, `Vector`/`Direction Towards`, `Players Within Radius`' center, `Nearest Walkable Position`, the raycast start/end) — so the zero-length guards still stop: `Normalize`/`Direction Towards`/`Angle Between Vectors` on `Null` meet their zero-length vector and stay `not_modeled`. A literal number, boolean, text or array is not a vector and stays `not_modeled`. `Horizontal Angle Towards`/`Vertical Angle Towards` are not modeled at all, so the question does not reach them. A probe is needed before this counts as measured. - Opaque display values (athena#113): the five display-only producers — `Icon String`, `Hero Icon String`, `Ability Icon String`, `Input Binding String` and `Custom Color` — yield a client rendering token no modeled construct reads, so the value carries the producer id and the evaluated arguments (`{ "type": "display", "value": { "producer", "args" } }`), never the glyph, key name or RGB. It flows through variables, arrays and the text/color arguments of HUD and effect actions; a `Custom String` holding one yields a `customString` display value (template as first argument) because the rendered text would carry the token's content. `==`/`!=` between two display values answer from the structure — identical producer and recursively-equal arguments; a `customString` display, whose content is the rendered text rather than its record, stops every comparison it touches, as does any comparison against a different structure or a literal, and any content read — the string reads, arithmetic, an array index, a sort key, the number/truthy/string coercions. HUD and effect `color` arguments now record the evaluated value: a `Color` member still serializes as the bare member id, a `Custom Color` as the display `Val`. No capture covers the client tokens; modeled, not measured. -- State cells and stored modifiers (athena#116): the version-2 `state` map gains a write side — a state-write action writes a cell over the declared map, a write defines a key the scenario never supplied, a read after a write returns the written value, and a read before any write of an undeclared key stays `missing_input`; every write records a `state` change (`player`, `key`, `old`, `new`). The writes, each by its catalog sentence: `setUltCharge` writes `ultCharge` ("sets the ultimate charge for one or more players as a percentage of maximum charge"), the `Number|Boolean` parameter reading `False`/`True` as `0`/`1`; `setHealth` ("sets the health of a player or players … This action only has an effect on living players. For dead players, use the Resurrect action instead") writes `health`, gated by the `isDead` read — a dead target is skipped, an unanswerable `isDead` is a `missing_input`; `setMaxHealth` ("as a percentage of their max health … will ensure that a player's current health will not exceed the new max health") writes `maxHealth` off the current-max read and clamps `health`; `kill`, `respawn` and `resurrect` write `isDead` — `respawn` also `health` to the `maxHealth` read ("with full health"), its spawn relocation not simulated — and `setFacing` `To World` writes the scenario `facing` to the direction's angles (`To Player` stays `not_modeled`: the catalog does not state the composition). The stored modifiers record a `modifier` change (action, player, evaluated non-target arguments) and land on the player's `modifiers` — nothing reads them back and no readable value moves: `setUltEnabled` → `ultEnabled`, `setAbility1Enabled`/`setAbility2Enabled` → `ability1Enabled`/`ability2Enabled`, `setDamageDealt`/`setDamageReceived`/`setHealingDealt`/`setHealingReceived` → `damageDealt`/`damageReceived`/`healingDealt`/`healingReceived` (scales on damage the model does not run), `setMoveSpeed`/`setGravity`/`setKnockbackReceived` → `moveSpeed`/`gravity`/`knockbackReceived` (movement modifiers no modeled read consumes — `isMoving`/`getSpeed` are scenario state, never derived from them), `setAllowedHeroes` → `allowedHeroes` (no hero read consults the list), `startForcingHero`/`stopForcingCurrentHero` → `forcedHero` written/removed, and `startForcingSpawn` → `forcedSpawnRoom` on each player of the team. The rest of PR #104's list — and the sibling ids in the same families (`stopDamageOverTime`, `stopAllDamageOverTime`, `stopAllHealingOverTime`, `stopFacing`, the player-variable `Chase *`) — stays `not_modeled`, each needing a model the run does not carry: the health-pool ids and composition (`addHealthPool`, `removeHealthPool`), the damage/heal-over-time and damage-modification ids (`startDamageOverTime`, `stopDamageOverTime`, `stopAllDamageOverTime`, `startHealingOverTime`, `stopHealingOverTime`, `stopAllHealingOverTime`, `startDamageModification`), the roster (`createDummyBot`, `destroyAllDummies`, `removeFromGame`), the entity ids (`createIcon`, `destroyAllIcons`) and text ids (`createInWorldText`, `createProgressBarInWorldText`, `destroyInWorldText`, `destroyProgressBarInWorldText`, `destroyAllInWorldText`), button and throttle forcing (`forceButtonPress`, `startForcingButton`, `stopForcingButton`, `startForcingThrottle`, `stopForcingThrottle`, `startThrottleInDirection`, `stopThrottleInDirection`), `cancelPrimaryAction` (the stun's duration is unstated), the dynamics of position, velocity and facing (`applyImpulse`, `startAcceleration`, `stopAcceleration`, `startFacing`, `stopFacing`, `startForcingPosition`, `stopForcingPosition`, `attachTo`, `detach`), the match clock and match state (`pauseMatchTime`, `unpauseMatchTime`, `setMatchTime`, `setSlowMotion`, `declareTeamVictory`, `restartMatch`), and the `Chase *` schedules (`chasePlayerVariableAtRate`, `chasePlayerVariableOverTime`, `chaseGlobalVariableAtRate`, `chaseGlobalVariableOverTime`). No capture covers any of these; modeled, not measured. +- State cells and stored modifiers (athena#116): the version-2 `state` map gains a write side — a state-write action writes a cell over the declared map, a write defines a key the scenario never supplied, a read after a write returns the written value, and a read before any write of an undeclared key stays `missing_input`; every write records a `state` change (`player`, `key`, `old`, `new`). The writes, each by its catalog sentence: `setUltCharge` writes `ultCharge` ("sets the ultimate charge for one or more players as a percentage of maximum charge"), the `Number|Boolean` parameter reading `False`/`True` as `0`/`1`; `setHealth` ("sets the health of a player or players … This action only has an effect on living players. For dead players, use the Resurrect action instead") writes `health`, gated by the `isDead` read — a dead target is skipped, an unanswerable `isDead` is a `missing_input`; `setMaxHealth` ("as a percentage of their max health … will ensure that a player's current health will not exceed the new max health") writes `maxHealth` off the current-max read and clamps `health`; `kill`, `respawn` and `resurrect` write `isDead` — `respawn` also `health` to the `maxHealth` read ("with full health"), its spawn relocation not simulated — and both revives refill the resolved `world` `health` pool, so a player `Damage` drained to zero takes damage and dies again; `setFacing` `To World` writes the scenario `facing` to the direction's angles (`To Player` stays `not_modeled`: the catalog does not state the composition). The stored modifiers record a `modifier` change (action, player, evaluated non-target arguments) and land on the player's `modifiers` — nothing reads them back and no readable value moves: `setUltEnabled` → `ultEnabled`, `setAbility1Enabled`/`setAbility2Enabled` → `ability1Enabled`/`ability2Enabled`, `setDamageDealt`/`setDamageReceived`/`setHealingDealt`/`setHealingReceived` → `damageDealt`/`damageReceived`/`healingDealt`/`healingReceived` (scales on damage the model does not run), `setMoveSpeed`/`setGravity`/`setKnockbackReceived` → `moveSpeed`/`gravity`/`knockbackReceived` (movement modifiers no modeled read consumes — `isMoving`/`getSpeed` are scenario state, never derived from them), `setAllowedHeroes` → `allowedHeroes` (no hero read consults the list), `startForcingHero`/`stopForcingCurrentHero` → `forcedHero` written/removed, and `startForcingSpawn` → `forcedSpawnRoom` on each player of the team. The rest of PR #104's list — and the sibling ids in the same families (`stopDamageOverTime`, `stopAllDamageOverTime`, `stopAllHealingOverTime`, `stopFacing`, the player-variable `Chase *`) — stays `not_modeled`, each needing a model the run does not carry: the health-pool ids and composition (`addHealthPool`, `removeHealthPool`), the damage/heal-over-time and damage-modification ids (`startDamageOverTime`, `stopDamageOverTime`, `stopAllDamageOverTime`, `startHealingOverTime`, `stopHealingOverTime`, `stopAllHealingOverTime`, `startDamageModification`), the roster (`createDummyBot`, `destroyAllDummies`, `removeFromGame`), the entity ids (`createIcon`, `destroyAllIcons`) and text ids (`createInWorldText`, `createProgressBarInWorldText`, `destroyInWorldText`, `destroyProgressBarInWorldText`, `destroyAllInWorldText`), button and throttle forcing (`forceButtonPress`, `startForcingButton`, `stopForcingButton`, `startForcingThrottle`, `stopForcingThrottle`, `startThrottleInDirection`, `stopThrottleInDirection`), `cancelPrimaryAction` (the stun's duration is unstated), the dynamics of position, velocity and facing (`applyImpulse`, `startAcceleration`, `stopAcceleration`, `startFacing`, `stopFacing`, `startForcingPosition`, `stopForcingPosition`, `attachTo`, `detach`), the match clock and match state (`pauseMatchTime`, `unpauseMatchTime`, `setMatchTime`, `setSlowMotion`, `declareTeamVictory`, `restartMatch`), and the `Chase *` schedules (`chasePlayerVariableAtRate`, `chasePlayerVariableOverTime`, `chaseGlobalVariableAtRate`, `chaseGlobalVariableOverTime`). No capture covers any of these; modeled, not measured. - Entity-creating display actions (athena#118): every display-entity create is recorded — `createHudText`, `progressBarHud`, `createInWorldText` and `createProgressBarInWorldText` on `hud`, `createEffect`, `createBeamEffect`, `createIcon`, `createProjectileEffect` and `playEffect` on `effects` — each with the catalog sentence's arguments and the evaluated `Visible To` recipients. Two id spaces follow the catalog's sentences: the four text creates share the `TextId` space `lastTextId` and the text destroys read (`getNumberOfTextIds` counts the live elements of those kinds), and the durable entity creates share the `EntityId` space `lastCreatedEntity` reads — a `playEffect` hands out none ("the last created entity" needs a create, not a play) and dies on its tick. `entityExists` answers the `Player|EntityId` union: a scenario player exists for the whole run; a number answers from the entity table — live entity of that id or not, so a text id or unknown id is a plain `false`, `Null` is `false`, and anything else is `not_modeled`. The destroy scoping is the catalog's: `destroyEffect`/`destroyAllEffects` name both `createEffect` and `createBeamEffect`, `destroyIcon`/`destroyAllIcons` the icons, and the two in-world destroy pairs their own kinds; a `projectileEffect` is durable but no destroy action names it, and `getNumberOfEntityIds` counts only `effect`/`beam`/`icon` — the kinds the catalog's sentence lists. The catalog defines no `Set ...`/`Update ...` action taking a text or entity id, so nothing mutates an element after creation. No capture covers any of this; modeled, not measured. - The match clock (athena#120): `Pause`/`Unpause Match Time`, `Set Match Time` and `Set Slow Motion` write a clock the run carries — a paused flag, a `Match Time` countdown and a slow-motion percent. An unpaused clock advances each tick by 16 ms scaled by the percent, on `Total Time Elapsed` and the countdown alike; a paused clock stands. `Total Time Elapsed` is the 15 ms start offset plus the elapsed game time; `Match Time` reads the countdown — a read before any `Set Match Time` is `not_modeled`, the lobby's phase limit being no input the scenario format carries, and a countdown reaching zero stops the run, the phase boundary it crosses being unmodeled. Every write records a `matchClock` change (`action`, `field` — `paused`, `matchTime` or `slowMotion` — `old`, `new`). How `Wait` seconds map to ticks under a non-100 factor is unmeasured, so a `Wait` — and a `Wait Until` whose timeout would suspend — then stops `not_modeled` naming the construct, and a wait suspended at 100% whose wake fires after the factor moved stops the same way, the wake encoding the same unmeasured mapping (a `Wait Until` wake its own condition re-check produced carries no schedule and still resumes). A clock argument that evaluates non-finite is `not_modeled` too. Status expiries compare `until` against the clock-driven `now`, so `Pause Match Time` freezes a `Has Status` expiry and `Set Slow Motion` slows it — the coupling is modeled, not measured. The `Is Match In Overtime` read stays `not_modeled`. +- Modeled player events (athena#47): player events arise two ways — a scenario `events` entry injected at `floor(time / 16 ms)`, and a modeled cause — so `Damage` and `Heal` raise the player events their effect implies. A positive `Damage` on a living target raises `Player Took Damage` on the target then `Player Dealt Damage` on a player dealer; a hit that brings health to zero writes `isDead` (the `Kill` cell) and raises `Player Died` then `Player Dealt Final Blow` — occurrence order, so a `Player Died` rule listed after a `Player Dealt Final Blow` rule still runs first, with raised events dispatching between rule-instance visits. A positive `Heal` on a living target raises `Player Received Healing` then `Player Dealt Healing`. A `Null` dealer raises no dealer-side event and binds `Attacker` to `Null`; a hit on an already-dead target raises nothing. Each raised event binds `Attacker` (the dealing or healing player), `Victim` (the target) and `Event Damage`/`Event Healing` (the applied amount), the same bindings on every event of one cause — the catalog names no per-event value list, so the uniform set is the modeled choice — and `attacker`/`victim` join the event-value table, an injected event supplying them as scenario player `id`s or `null`. A busy or waiting rule queues a raised event like an injected one. Raised events bound at 1024 per tick — a rule whose own actions re-raise its kind would fan out without bound; past the bound the run stops `not_modeled`. No capture covers the client's event ordering, its binding sets, or its queue bound; modeled, not measured. diff --git a/docs/scenario-format.md b/docs/scenario-format.md index 932ffcd..886beb0 100644 --- a/docs/scenario-format.md +++ b/docs/scenario-format.md @@ -90,7 +90,7 @@ JSON Schema cannot express some rules. The `athena` loader must check them and r - Player `id` values are unique. - Player `slot` values are unique. -- Every `eventPlayer` and every expectation `player` is the `id` of a player in the file. +- Every `eventPlayer` and every expectation `player` is the `id` of a player in the file; every `attacker`/`victim` event value is a player `id` or `null`. - Every `hero` is a hero name known to `workshop-rs` (decision 3): the canonical `Hero` member id of the catalog (for example `JUNKER_QUEEN`) or its en-US spelling (`Junker Queen`), matched case-insensitively. - `map`, when present, is a map name known to `workshop-rs`: the canonical `Map` member id of the catalog (for example `KINGS_ROW`) or its en-US spelling (`King's Row`), matched case-insensitively. - `gameMode`, when present, is a game-mode name known to `workshop-rs`: the canonical `Gamemode` member id of the catalog (for example `SKIRMISH`) or its en-US spelling (`Skirmish`), matched case-insensitively. @@ -114,7 +114,7 @@ The value is `null`, as in decision 13. No report is made. The trace shows the e - The `hero` of a player, when the program reads it and the player has no `hero` — `Hero Of`, or a player `Is Hero Being Played` or `Players On Hero` reads on the team. - The `team` of a player, when the program reads it and the player has no `team`. - The event player, when the program reads it and the event has no `eventPlayer`. -- An event value, when the program reads it and the event has no such entry in `values`. +- An event value, when the program reads it and an injected event has no such entry in `values`. An event a `Damage`/`Heal` raised binds only the values its cause attached; a read it does not bind is `not_modeled`, not a missing input — no scenario field supplies it. - The held state of a button, when `Is Button Held` reads it and the player's `buttons` does not list the slot. - The random source, when a `Random *` value reads it and the scenario has no `random`. - The server load, when a `Server Load`, `Server Load Average` or `Server Load Peak` value reads it and the scenario has no `serverLoad`. @@ -130,7 +130,7 @@ The value is `null`, as in decision 13. No report is made. The trace shows the e An input of group 2 is **missing** when the program reads it during the run and the scenario does not supply it. The simulator never fills it in. It stops the run at that read, and the trace names the input. -Modeled reads in version 1: the event player (`Event Player`), a player's hero (`Hero Of`), team (`Team Of`) and lobby slot (`Slot Of`, read from `slot`), a player variable, a button's held state (`Is Button Held`, looked up in the player's `buttons` by the evaluated slot), the server load (`Server Load`, `Server Load Average` and `Server Load Peak`, read from `serverLoad`), the map (`Current Map`, read from `map`, yielding the catalog `Map` member id as text — the form a `Map` literal yields, so the two compare equal), the match phase (`Is Waiting For Players`, `Is Assembling Heroes`, `Is In Setup`, `Is Game In Progress`, `Is Between Rounds` and `Is Match Complete`, each read from `matchPhase` and true exactly for the declared phase), the game mode (`Current Game Mode`, read from `gameMode`, yielding the catalog `Gamemode` member id as text — the form a `Game Mode` literal yields, so the two compare equal), and the scalar event values `eventDamage`, `eventHealing`, `eventWasCriticalHit`, `eventWasEnvironment` and `eventWasHealthPack` — each looked up in `values` by its catalog name. `Host Player` returns the player marked `host`; without one it stops the run as `not_modeled`. `Local Player` returns the player marked `local`; without one the read is a group 2 missing input naming `local`. `Has Spawned` of a player with a `hero` is true from the first tick; `Is Alive` answers the `isDead` read where one answers — a declared `state.isDead` or a cell an action wrote — and the same hero model otherwise (modeled, not measured, [architecture.md](architecture.md#choices-no-capture-covers-yet)). For a player without a `hero`, `Has Spawned` — and `Is Alive` when `isDead` cannot answer — stops the run as `not_modeled`. `Is Hero Being Played(hero, team)` is true when at least one scenario player on `team` — `All Teams` selects every player — has `hero` as its declared `hero` (modeled, not measured; the hero-selection screen is outside the model); a player on the team without a `hero` is a group 2 missing input, and a `hero` or `team` argument that is not a literal catalog member after evaluation is `not_modeled`. `Players On Hero(hero, team)` yields the scenario players on `team` that have `hero` as their declared `hero`, in roster order, under the same declared-`hero` read: a player on the team without a `hero` is a group 2 missing input, and a `hero` or `team` argument that is not a literal catalog member after evaluation is `not_modeled`. Other event-scoped values (`attacker`, `victim`, `eventAbility`, `eventDirection`) are not modeled and stop the run as `not_modeled`. An injected event fires every rule whose event kind matches `kind` and whose `team`/`player` filters the `eventPlayer` satisfies — `All` always matches, `Team 1`/`Team 2` match the declared `team`, a hero name matches the declared `hero`; an event without an `eventPlayer` satisfies only `All`/`All`, and evaluating a filtered rule against it reads the event player, a group 2 missing input. A `Slot N` filter matches the declared `slot` — a player without one does not match — and a filter no evaluation reaches never fires and is not an error. An `Ongoing - Each Player` rule becomes one instance per matching player with `Event Player` bound to it ([simulator-interface.md](simulator-interface.md#player-scoped-rules)). A player's `position` is read by `Position Of`, by the `Player` operands of the vector values and by the `Player` position arguments of the geometry reads (`Ray Cast *`, `Is In Line of Sight`, `Nearest Walkable Position`) and by a `Player` destination of `Teleport`; a player's `facing` is read by `Facing Direction Of`, `Vertical Facing Angle Of` (the vertical component) and `Local`/`World Vector Of` — each a group 2 input named `position`/`facing`. `Players Within Radius(center, radius, team, losCheck)` yields the scenario players on `team` whose `position` lies within `radius` of `center`, in roster order — the catalog notes dead players are picked up, so no life-state filter applies — and `losCheck` is evaluated but holds in the empty world. An objective position is read by `Objective Position(i)` from `objectives[i]` — a group 2 input named `objectives[i]`, `i` the evaluated index. +Modeled reads in version 1: the event player (`Event Player`), a player's hero (`Hero Of`), team (`Team Of`) and lobby slot (`Slot Of`, read from `slot`), a player variable, a button's held state (`Is Button Held`, looked up in the player's `buttons` by the evaluated slot), the server load (`Server Load`, `Server Load Average` and `Server Load Peak`, read from `serverLoad`), the map (`Current Map`, read from `map`, yielding the catalog `Map` member id as text — the form a `Map` literal yields, so the two compare equal), the match phase (`Is Waiting For Players`, `Is Assembling Heroes`, `Is In Setup`, `Is Game In Progress`, `Is Between Rounds` and `Is Match Complete`, each read from `matchPhase` and true exactly for the declared phase), the game mode (`Current Game Mode`, read from `gameMode`, yielding the catalog `Gamemode` member id as text — the form a `Game Mode` literal yields, so the two compare equal), and the event values `attacker`, `victim`, `eventDamage`, `eventHealing`, `eventWasCriticalHit`, `eventWasEnvironment` and `eventWasHealthPack` — each looked up in the event's `values` by its catalog name (`attacker` and `victim` hold player ids, a scenario `id` text or `null`), including the values a `Damage`/`Heal` action binds on the events it raises ([simulator-interface.md](simulator-interface.md#player-events)). `Host Player` returns the player marked `host`; without one it stops the run as `not_modeled`. `Local Player` returns the player marked `local`; without one the read is a group 2 missing input naming `local`. `Has Spawned` of a player with a `hero` is true from the first tick; `Is Alive` answers the `isDead` read where one answers — a declared `state.isDead` or a cell an action wrote — and the same hero model otherwise (modeled, not measured, [architecture.md](architecture.md#choices-no-capture-covers-yet)). For a player without a `hero`, `Has Spawned` — and `Is Alive` when `isDead` cannot answer — stops the run as `not_modeled`. `Is Hero Being Played(hero, team)` is true when at least one scenario player on `team` — `All Teams` selects every player — has `hero` as its declared `hero` (modeled, not measured; the hero-selection screen is outside the model); a player on the team without a `hero` is a group 2 missing input, and a `hero` or `team` argument that is not a literal catalog member after evaluation is `not_modeled`. `Players On Hero(hero, team)` yields the scenario players on `team` that have `hero` as their declared `hero`, in roster order, under the same declared-`hero` read: a player on the team without a `hero` is a group 2 missing input, and a `hero` or `team` argument that is not a literal catalog member after evaluation is `not_modeled`. Other event-scoped values (`eventAbility`, `eventDirection`) are not modeled and stop the run as `not_modeled`. An injected event fires every rule whose event kind matches `kind` and whose `team`/`player` filters the `eventPlayer` satisfies — `All` always matches, `Team 1`/`Team 2` match the declared `team`, a hero name matches the declared `hero`; an event without an `eventPlayer` satisfies only `All`/`All`, and evaluating a filtered rule against it reads the event player, a group 2 missing input. A `Slot N` filter matches the declared `slot` — a player without one does not match — and a filter no evaluation reaches never fires and is not an error. An `Ongoing - Each Player` rule becomes one instance per matching player with `Event Player` bound to it ([simulator-interface.md](simulator-interface.md#player-scoped-rules)). A player's `position` is read by `Position Of`, by the `Player` operands of the vector values and by the `Player` position arguments of the geometry reads (`Ray Cast *`, `Is In Line of Sight`, `Nearest Walkable Position`) and by a `Player` destination of `Teleport`; a player's `facing` is read by `Facing Direction Of`, `Vertical Facing Angle Of` (the vertical component) and `Local`/`World Vector Of` — each a group 2 input named `position`/`facing`. `Players Within Radius(center, radius, team, losCheck)` yields the scenario players on `team` whose `position` lies within `radius` of `center`, in roster order — the catalog notes dead players are picked up, so no life-state filter applies — and `losCheck` is evaluated but holds in the empty world. An objective position is read by `Objective Position(i)` from `objectives[i]` — a group 2 input named `objectives[i]`, `i` the evaluated index. In version 2, a player that declares `state` answers the no-extra-argument player-state reads from it (decision 19): `Health`, `Max Health`, `Normalized Health`, `Velocity Of`, `Speed Of` (the length of `velocity`), `Eye Position`, `Is Dead`, `Is Using Ultimate`, `Is Meleeing`, `Is Firing Secondary`, `Is In Alternate Form`, `Is Communicating Any Emote`, `Is In Spawn Room`, `Is Dummy Bot`, `Ultimate Charge Percent` and `Is Moving`, and `Is Using Ability 1`, `Is Using Ability 2`, `Is Firing Primary` (the `isFiringPrimaryFire` key) and `Is Duplicating` (the `isDuplicatingAHero` key) — each scenario-supplied, not simulated. diff --git a/docs/simulator-interface.md b/docs/simulator-interface.md index 4be3ef2..6ddc65b 100644 --- a/docs/simulator-interface.md +++ b/docs/simulator-interface.md @@ -74,6 +74,10 @@ The `team` and player (`target`) filters of a rule event resolve against the sce - An `Ongoing - Each Player` rule becomes one instance per matching scenario player, ordered by scenario `id`. Each instance binds `Event Player` to its player — also inside a subroutine it calls — and has its own condition edge, `Wait`/`Wait Until` and wait-behavior state. A filter matching nobody yields no instance: the rule never fires and is not an error. - A player event rule (`Player Died`, `Player Joined Match`, …) runs for an injected event only when the event's `eventPlayer` satisfies the filters. An event that names no `eventPlayer` satisfies only `All`/`All`; evaluating a filtered rule against it is a `missing_input` of kind `eventPlayer`. +### Player events + +Player events also arise from a modeled cause — `Damage`/`Heal` raise the player events their effect implies on the same tick. `Damage` on a living target raises `Player Took Damage` (the target) then `Player Dealt Damage` (a player dealer); a hit that brings health to zero writes `isDead` (like `Kill`) and raises `Player Died` then `Player Dealt Final Blow`. `Heal` raises `Player Received Healing` then `Player Dealt Healing`, on the same gate; a `Null` dealer raises no dealer-side event, a dead target none. Each binds `Event Player`, `Attacker` (`Null` for none), `Victim` and `Event Damage`/`Event Healing` (the applied amount); `Event Ability`/`Event Direction` stay `not_modeled`. Raised events run their rules in occurrence order, bound at 1024 per tick. **Modeled, not measured** — see [architecture.md](architecture.md#choices-no-capture-covers-yet). + ### 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` and `Value In Array` 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. diff --git a/docs/trace-format.md b/docs/trace-format.md index 6d9c959..94badc8 100644 --- a/docs/trace-format.md +++ b/docs/trace-format.md @@ -23,7 +23,7 @@ Each `rules` element: | --- | --- | | `rule` | The rule's index among the program's enabled rules, in file order. | | `name` | The rule's name. | -| `eventPlayer` | The scenario `id` of the run's `Event Player`: an injected event's `eventPlayer`, or the player an `Ongoing - Each Player` run is bound to; `null` for `Ongoing - Global` runs and events without one. | +| `eventPlayer` | The scenario `id` of the run's `Event Player`: the triggering event's player — an injected event's `eventPlayer` or the player a `Damage`/`Heal` raised the event on — or the player an `Ongoing - Each Player` run is bound to; `null` for `Ongoing - Global` runs and events without one. | | `ended` | How the execution ended on the tick: `finished` (stepped past its last action), `waiting` with `wake` (suspended; `wake` is the tick the wait expires — the resume tick for `Wait`, the timeout tick for `Wait Until`, whose condition can resume it earlier), `stopped` (the run's `not_modeled`/`missing_input` stop was inside this rule — an action, a condition check or an event filter; `rule` then names the rule that owns the stopped construct, the subroutine's inside `Call Subroutine`) or `aborted` (killed by an `Abort When False` wait going false or an `Abort`/`Abort If` action). | Each `changes` element has `kind` plus its own fields; `old`/`new` serialize as the `Val` form: