Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 130 additions & 21 deletions crates/athena-sim/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)?;
}
}
}
}
Expand All @@ -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" => {
Expand Down Expand Up @@ -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<bool, Stop> {
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<f64>); 3] {
Expand All @@ -292,3 +362,42 @@ fn pools(world: &PlayerWorld) -> [(&'static str, Option<f64>); 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<String, Val>,
) -> 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<String, Val> {
BTreeMap::from([
("attacker".to_string(), cause.clone()),
("victim".to_string(), Val::Player(victim.to_string())),
(key.to_string(), Val::Number(amount)),
])
}
18 changes: 15 additions & 3 deletions crates/athena-sim/src/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ fn missing<T>(
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`,
Expand Down Expand Up @@ -625,6 +629,14 @@ pub(crate) fn call(name: &str, args: &[Value], env: &Env) -> Result<Val, Stop> {
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,
Expand Down
4 changes: 2 additions & 2 deletions crates/athena-sim/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ use crate::{Change, MissingInput, MissingInputKind, Stop, Val};
pub(crate) struct Env<'a> {
pub globals: &'a BTreeMap<String, Val>,
pub players: &'a BTreeMap<String, Player>,
/// 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 —
Expand Down
Loading
Loading