diff --git a/CONTEXT.md b/CONTEXT.md index b0936f0d4..1b643a8d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -206,7 +206,9 @@ and authorizes no bot gameplay. Frozen cancels Foreground Actions and authorizes **Bot Objective**: A retained purpose with its destination, stage, deadline, catalog revision, and verified progress. -Tactical interruption does not replace the objective. +Tactical interruption does not replace the objective. A companion in a human-led party retains the +leader identity while refreshing the destination from current party facts; that refresh does not +replace a retained cast or its identity. **Foreground Action**: The single movement or cast retained by one Bot Controller generation. diff --git a/docs/package-api.md b/docs/package-api.md index 512f0de1a..b7ea7c180 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -97,15 +97,20 @@ encounter content; the kernel exists for Packages. Existing verbs keep `fn verb(ctx, actor_guid, ..) -> Result<(), String>`. The table in `module/src/actor.rs` lists their contracts. +`actor::cast_readiness(ctx, actor_guid, spell_id, target_guid)` applies the same read-only spellbook, +supported-lifecycle, range, line-of-sight, and ordinary cast Gates used at cast start. A Package can +turn an `OutOfRange` or `NoLineOfSight` Refusal into a movement prerequisite without spending power, +starting a cooldown, or creating a cast. + `actor::request_cast(ctx, actor_guid, spell_id, target_guid)` returns `Result`. `Started` carries a Cast Handle. `Waiting` carries an existing cast's original identity, spell, target, and current due time, even when the new request names a different spell or target. It never restarts that cast. `Resolved` means effects dispatched synchronously, which does not imply a projectile hit. Channeled spells return `UnsupportedChannel` until bot requests can retain their lifecycle. Client casts keep their existing channel behavior. -`actor::cast_at` is the compatibility adapter that discards this distinction. These explicit-guid -requests preserve the Actor API's spellbook bypass. They do not prove the Character knows the spell; -capability selection must enforce known, supported spells before proposing them. +`actor::cast_at` is the compatibility adapter that discards this distinction. Character Actor +requests require the spell in `game_player_spell`. Creature and triggered spell-engine entries keep +their existing spellbook exemption. A scheduled cast ends through `on_cast_finished`. Its payload carries the caster, target, scheduled identity, and `spell::CastFinish`. Packages must match the scheduled identity before updating retained @@ -114,6 +119,7 @@ does not fire this hook. `on_cast_resolved` keeps its existing effect-dispatch c `spell::pending_cast` reads the current Cast Handle by caster. Refusal kinds are supplied by the owning Gate. `Other` preserves a Gate's message when no current caller needs a separate policy. +`UnlearnedSpell` and `NoLineOfSight` identify the spellbook and targeted visibility Gates. `spell::cancel_cast_attempt(ctx, caster_guid, scheduled_id)` cancels only that scheduled cast. `spell::expire_cast_attempt(ctx, caster_guid, scheduled_id, deadline_micros)` also requires the @@ -124,6 +130,11 @@ caller's action deadline to have passed. They return whether they removed the ca `character_by_guid`, `character_by_name`, `entity_by_owner`, `acting_entity_by_guid`, `entities_near`, `nearest_entity`, `in_same_partition`, `require_operator`. +`group::party_facts(ctx, character_guid)` reads the Character's local durable party mirror. It names +the leader and every member, with nullable live position, health, and death facts for each member. +An absent membership returns `Ok(None)`. A membership whose Group row is missing returns the typed +`PartyFactsUnavailable`, so a Package can hold party control instead of starting solo behavior. + ### Package Config An Operator-tunable value, seeded by the Package and edited without a republish. diff --git a/module/src/actor.rs b/module/src/actor.rs index 5fe3de99c..366630663 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -14,6 +14,7 @@ //! | `ranged_attack` | `combat::apply_start_ranged_attack` | `attack` gates + ranged weapon equipped (slot 17) | //! | `stop_attack` | `combat::stop_attack_for` | unconditional disarm of the actor's outgoing melee row | //! | `cast_at` | `spell::request_cast` | normal cast lifecycle; an existing timed cast waits; level comes from the live entity | +//! | `cast_readiness` | `spell::cast_readiness` | read-only spellbook, supported-lifecycle, and cast Gate check | //! | `request_cast` | `spell::request_cast` | typed start, waiting, and Refusal; completion uses `on_cast_finished` | //! | `accept_quest` | `quest::apply_accept_quest` | alive + giver in range offering the quest + level/race/class/prereq/duplicate gates | //! | `stage_quest` | `quest::grant_quest_unchecked` | HARNESS/BOT staging: same row shape, all accept gates SKIPPED (giver-less) | @@ -79,6 +80,7 @@ macro_rules! package_only { package_only! { pub(crate) use crate::combat::apply_start_attack as attack; } package_only! { pub(crate) use crate::combat::request_attack as request_attack; } package_only! { pub(crate) use crate::spell::request_cast as request_cast; } +package_only! { pub(crate) use crate::spell::cast_readiness as cast_readiness; } debug_only! { pub(crate) use crate::combat::apply_start_ranged_attack as ranged_attack; } /// Disarm the actor's outgoing auto-attack (melee or ranged). Shape adapter ONLY: the core returns diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 70ccc2607..cf94212ba 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -428,6 +428,27 @@ pub fn debug_force_cast( crate::spell::resolve_cast(ctx, character_guid, spell_id, e.level as u8) } +fn resolve_debug_cast_at( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + target_guid: u64, +) -> Result<(), String> { + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + // Fixture spells can be staged without being learned. Keep that debug-only bypass while using + // the owning synchronous resolver for costs, targets, effects, and other cast Gates. + crate::spell::resolve_cast_at( + ctx, + caster_guid, + spell_id, + caster.level as u8, + target_guid, + false, + false, + None, + ) +} + /// Like `debug_force_cast` but directs the cast at an explicit `target_guid` instead of self — /// used to test targeted interrupts (e.g. Kick: caster A casts Kick on target B who is mid-cast). /// For interrupt testing the `lockout_ms` value is read from `game_spell.duration_ms` for the @@ -439,8 +460,7 @@ pub fn debug_force_cast_at( spell_id: u32, target_guid: u64, ) -> Result<(), String> { - // Actor verb: sources the caster level from the live entity and resolves synchronously. - crate::actor::cast_at(ctx, caster_guid, spell_id, target_guid) + resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid) } /// Set `character_guid`'s level and recompute `max_health`/`max_power` from the real stat curve @@ -1038,8 +1058,8 @@ pub fn debug_cast_at( target_guid: u64, ) -> Result<(), String> { // Debug direct cast resolves synchronously — not a timed-cast completion (instant packet - // sequence); the actor verb sources the caster level from the live entity. - crate::actor::cast_at(ctx, caster_guid, spell_id, target_guid) + // sequence). It bypasses player spellbook admission so fixtures can cast rows they just staged. + resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid) } /// Begin a (possibly cast-timed) cast from `caster_guid` AT `target_guid` — drives `begin_cast` so a diff --git a/module/src/debug/repair.rs b/module/src/debug/repair.rs index 2cafa6ec5..cb4511b66 100644 --- a/module/src/debug/repair.rs +++ b/module/src/debug/repair.rs @@ -57,6 +57,8 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { crate::seed::seed_createinfo_spells(ctx); let createinfo = ctx.db.game_createinfo_spell().count(); + let lesser_heal_target = crate::seed::repair_lesser_heal_target(ctx); + crate::seed::seed_spell_groups(ctx); crate::items::seed_compatibility_enchantments(ctx); let spell_groups = ctx.db.game_spell_group().count(); @@ -350,6 +352,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { // already present" signal worth stamping separately. let total = talents + createinfo + + lesser_heal_target + spell_groups + pw_shield + fixture_catalogue diff --git a/module/src/group.rs b/module/src/group.rs index b896a2450..ced7fef9b 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -472,6 +472,85 @@ pub(crate) fn group_of(ctx: &ReducerContext, character_guid: u64) -> Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PartyFacts { + pub group_id: u64, + pub leader_guid: u64, + pub members: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PartyFactsUnavailable { + pub group_id: u64, +} + +/// Read one Character's local durable party mirror and current member facts. Membership remains +/// useful when a member has no live entity on this Shard, so those facts are nullable. +#[cfg_attr(not(has_packages), allow(dead_code))] +pub fn party_facts( + ctx: &ReducerContext, + character_guid: u64, +) -> Result, PartyFactsUnavailable> { + let Some(member) = group_of(ctx, character_guid) else { + return Ok(None); + }; + let group = + ctx.db + .game_group() + .group_id() + .find(member.group_id) + .ok_or(PartyFactsUnavailable { + group_id: member.group_id, + })?; + let members = members_of(ctx, member.group_id) + .into_iter() + .map(|member| { + let unit = ctx + .db + .game_world_entity() + .guid() + .find(member.character_guid) + .map(|entity| PartyUnitFacts { + map_id: entity.map_id, + instance_id: entity.instance_id, + x: entity.x, + y: entity.y, + z: entity.z, + health: entity.health, + max_health: entity.max_health, + dead: entity.dead, + }); + PartyMemberFacts { + character_guid: member.character_guid, + unit, + } + }) + .collect(); + Ok(Some(PartyFacts { + group_id: member.group_id, + leader_guid: group.leader_guid, + members, + })) +} + /// Resolve a membership and its required parent for mutation cores. `Ok(None)` means the /// Character has no membership; a membership without its Group is a durable invariant failure. fn checked_group_membership( diff --git a/module/src/gw.rs b/module/src/gw.rs index 863fa3632..5ddec4813 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -301,9 +301,9 @@ pub fn gw_cast_at( crate::actor::cast_at(ctx, actor_guid, spell_id, target_guid) } -/// [`crate::spell::scheduler::apply_cast_spell_at`] behind the gateway gate — the GROUND-TARGET +/// [`crate::spell::scheduler::apply_cast_spell_at`] behind the gateway gate. The GROUND-TARGET /// cast form (`CMSG`-routed `cast_spell_at`): spellbook + NaN + self-target-default gates, target -/// coords carried. Distinct from [`gw_cast_at`], which is the unit-target instant core. +/// coords carried. [`gw_cast_at`] is the unit-target Actor form and applies the same spellbook Gate. #[reducer] pub fn gw_cast_spell_at( ctx: &ReducerContext, @@ -1323,9 +1323,9 @@ pub fn gw_loot_master_give( // Spells (batch B) // =========================================================================================== -/// [`crate::spell::do_cast_spell`] with the caster named by guid — the SPELLBOOK-GATED player cast -/// (`target_guid == 0` self-casts). Distinct from [`gw_cast_at`], which drives `resolve_cast_at` -/// directly and skips that gate. +/// [`crate::spell::do_cast_spell`] with the caster named by guid. This is the client player-cast form +/// (`target_guid == 0` self-casts). [`gw_cast_at`] applies the same spellbook and cast Gates through +/// the typed Actor request. #[reducer] pub fn gw_cast_spell( ctx: &ReducerContext, diff --git a/module/src/seed.rs b/module/src/seed.rs index d6719e8f5..0b8dd2b3d 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -905,7 +905,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { spell(772, "Rend", 1, 10, 0, 5, 21000, 1, 0, true, 0); effect(772, 0, 0x90, 7, 3000, 1, 0, 0); // bleed 7 dmg / 3s on the target enemy (T_TARGET_ENEMY) spell(2050, "Lesser Heal", 0, 30, 1500, 40, 0, 2, 0, false, 0); - effect(2050, 0, 0x02, 50, 0, 0, 0, 0); // heal self 50 + effect(2050, 0, 0x02, 50, 0, 2, 0, 0); // heal the named ally for 50 spell(133, "Fireball", 0, 0, 0, 30, 0, 4, 0, false, 0); effect(133, 0, 0x01, 20, 0, 1, 0, 0); // 20 fire dmg to an enemy (lethal -> kill_creature) spell( @@ -1328,6 +1328,78 @@ fn seed_spell_registry(ctx: &ReducerContext) { seed_spell_groups(ctx); } +#[cfg(feature = "debug_reducers")] +fn legacy_lesser_heal_header(spell: &Spell) -> bool { + spell.spell_id == 2050 + && spell.name == "Lesser Heal" + && spell.power_type == 0 + && spell.cost == 30 + && spell.cast_time_ms == 1500 + && spell.gcd_ms == 1500 + && spell.cooldown_ms == 0 + && spell.range_yd == 40 + && spell.duration_ms == 0 + && spell.school_mask == 2 + && spell.dispel_type == 0 + && spell.mechanic == 0 + && spell.max_stacks == 0 + && spell.aura_interrupt == 0 + && spell.attributes == 0 + && spell.spell_level == 0 + && spell.max_level == 0 + && !spell.is_negative + && spell.cast_flags == 0 + && spell.stances == 0 + && spell.family_name == 0 + && spell.family_flags == 0 + && spell.proc_flags == 0 + && spell.proc_chance == 0 + && spell.proc_charges == 0 +} + +#[cfg(feature = "debug_reducers")] +fn legacy_lesser_heal_effect(effect: &SpellEffect) -> bool { + effect.id == (2050u64 << 2) + && effect.spell_id == 2050 + && effect.effect_index == 0 + && effect.kind == 0x02 + && effect.base_points == 50 + && effect.die_sides == 0 + && effect.per_level == 0.0 + && effect.period_ms == 0 + && effect.target == crate::spell::T_SELF + && effect.radius_yd == 0.0 + && effect.chain_targets == 0 + && effect.trigger_spell == 0 + && effect.effect_mechanic == 0 + && effect.p0 == 0 + && effect.p0_kind == 0 + && effect.p1 == 0 + && effect.script_id == 0 + && !effect.enters_combat +} + +/// Repair only the exact former hand-authored Lesser Heal row. Imported or tuned spell data does +/// not match the complete header and effect shapes and remains authoritative. +#[cfg(feature = "debug_reducers")] +pub(crate) fn repair_lesser_heal_target(ctx: &ReducerContext) -> u64 { + let Some(spell) = ctx.db.game_spell().spell_id().find(2050) else { + return 0; + }; + let effects = ctx.db.game_spell_effect(); + let mut spell_effects: Vec<_> = effects.by_spell().filter(&2050u32).take(2).collect(); + if spell_effects.len() != 1 { + return 0; + } + let mut effect = spell_effects.pop().unwrap(); + if !legacy_lesser_heal_header(&spell) || !legacy_lesser_heal_effect(&effect) { + return 0; + } + effect.target = crate::spell::T_TARGET_ALLY; + effects.id().update(effect); + 1 +} + /// Stratum 4 — scheduler arming: the event reaper, instance reaper, creature movement tick, melee /// swing tick, aura-expiry tick, ground-AoE damage tick, and weather roll. Runs last so nothing /// fires against a half-seeded database. diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index a8b572d36..73032690a 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -823,6 +823,108 @@ fn check_cast_gate_prefix( Ok(()) } +fn target_kind_requires_los(target: u8) -> bool { + matches!( + target, + T_TARGET_ENEMY | T_TARGET_ALLY | T_TARGET_ANY | T_AREA_ENEMY | T_AREA_ALLY | T_CHAIN_ENEMY + ) +} + +fn targeted_los_applies( + caster_is_player: bool, + caster_guid: u64, + target_guid: u64, + effects: &[SpellEffect], +) -> bool { + caster_is_player + && target_guid != 0 + && target_guid != caster_guid + && effects + .iter() + .any(|effect| target_kind_requires_los(effect.target)) +} + +fn check_targeted_los( + ctx: &ReducerContext, + caster: &WorldEntity, + effects: &[SpellEffect], + target_guid: u64, +) -> Result<(), CastRefusal> { + if !targeted_los_applies(caster.is_player(), caster.guid, target_guid, effects) { + return Ok(()); + } + let Some(target) = ctx.db.game_world_entity().guid().find(target_guid) else { + return Ok(()); + }; + if (caster.map_id, caster.instance_id) != (target.map_id, target.instance_id) { + return Ok(()); + } + if !crate::nav::has_los( + ctx, + caster.map_id, + caster.instance_id, + (caster.x, caster.y, caster.z), + (target.x, target.y, target.z), + ) { + return Err(CastRefusal::new( + CastRefusalKind::NoLineOfSight, + format!("target {target_guid} is not in line of sight"), + )); + } + Ok(()) +} + +/// Read the Gates that own a direct cast start. Callers may use the typed result to plan a +/// prerequisite, but the cast entry checks the same Gates again before it changes state. +pub(crate) fn check_cast_start_gates( + ctx: &ReducerContext, + caster: &WorldEntity, + spell_id: u32, + target_guid: u64, + admission: CreatureSpellCasterAdmission, +) -> Result<(), CastRefusal> { + let hdr = ctx + .db + .game_spell() + .spell_id() + .find(spell_id) + .ok_or_else(|| format!("unknown spell {spell_id}"))?; + let mut effects: Vec = ctx + .db + .game_spell_effect() + .by_spell() + .filter(&spell_id) + .collect(); + effects.sort_by_key(|effect| (effect.kind != E_INTERRUPT, effect.effect_index)); + check_cast_gates_with_admission( + ctx, + caster, + &hdr, + &effects, + target_guid, + spell_id, + caster.level as u8, + admission == CreatureSpellCasterAdmission::DeadCreatureCallback, + )?; + check_targeted_los(ctx, caster, &effects, target_guid) +} + +pub(crate) fn check_pending_cast_los( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + target_guid: u64, +) -> Result<(), CastRefusal> { + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + let effects: Vec = ctx + .db + .game_spell_effect() + .by_spell() + .filter(&spell_id) + .collect(); + check_targeted_los(ctx, &caster, &effects, target_guid) +} + #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] // One gate per refusal a cast can hit. fn check_cast_gate_suffix( @@ -1298,6 +1400,7 @@ pub(crate) fn begin_cast_with_admission( level, admission == CreatureSpellCasterAdmission::DeadCreatureCallback, )?; + check_targeted_los(ctx, &caster, &effects, target_guid)?; // A NEW cast breaks any channel already in progress (vanilla: casting interrupts your channel). Fired // here at the cast ENTRY so it covers both the instant and the timed branch — and BEFORE the new @@ -1401,6 +1504,27 @@ pub(crate) fn begin_cast_with_admission( Ok(CastStart::Started(cast.into())) } +#[cfg(test)] +mod companion_los_tests { + use super::*; + + #[test] + fn los_target_kinds_exclude_self_and_scripted_spells() { + for target in [ + T_TARGET_ENEMY, + T_TARGET_ALLY, + T_TARGET_ANY, + T_AREA_ENEMY, + T_AREA_ALLY, + T_CHAIN_ENEMY, + ] { + assert!(target_kind_requires_los(target)); + } + assert!(!target_kind_requires_los(T_SELF)); + assert!(!target_kind_requires_los(T_SCRIPTED)); + } +} + /// True when a spell effect's `kind` should reach the passive-apply path (`apply_spell_auras`'s /// `aura_apply` loop) — exactly the aura-kind effects (`KIND_AURA_BIT` set), the SAME split the real cast /// path already uses to route an effect to `aura_apply` vs its instant handler (`apply_effect` above, diff --git a/module/src/spell/outcome.rs b/module/src/spell/outcome.rs index bebb8d807..d87a89e17 100644 --- a/module/src/spell/outcome.rs +++ b/module/src/spell/outcome.rs @@ -13,6 +13,8 @@ pub enum CastRefusalKind { OutOfRange, InsufficientPower, UnsupportedChannel, + UnlearnedSpell, + NoLineOfSight, } /// The owning Gate supplies the reason. `Other` preserves a Gate with no caller-specific policy. @@ -85,18 +87,22 @@ pub enum CastFinish { Expired, } -/// A request never replaces a pending cast. Explicit cancellation is a separate operation. +/// Check whether a Character can start a bot-owned cast without changing gameplay state. +/// Creature and triggered casts use their existing spell-engine entries instead. #[cfg_attr(not(has_packages), allow(dead_code))] -pub(crate) fn request_cast( +pub(crate) fn cast_readiness( ctx: &ReducerContext, caster_guid: u64, spell_id: u32, target_guid: u64, -) -> Result { - if let Some(cast) = pending_cast(ctx, caster_guid) { - return Ok(CastStart::Waiting(cast)); - } +) -> Result<(), CastRefusal> { let caster = crate::helpers::live_entity(ctx, caster_guid)?; + if caster.is_player() && !knows_spell(ctx, caster_guid, spell_id) { + return Err(CastRefusal::new( + CastRefusalKind::UnlearnedSpell, + format!("spell {spell_id} is not in the caster's spellbook"), + )); + } if ctx .db .game_spell() @@ -109,6 +115,28 @@ pub(crate) fn request_cast( "bot cast requests do not yet retain channels".to_string(), )); } + check_cast_start_gates( + ctx, + &caster, + spell_id, + target_guid, + CreatureSpellCasterAdmission::Living, + ) +} + +/// A request never replaces a pending cast. Explicit cancellation is a separate operation. +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn request_cast( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + target_guid: u64, +) -> Result { + if let Some(cast) = pending_cast(ctx, caster_guid) { + return Ok(CastStart::Waiting(cast)); + } + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + cast_readiness(ctx, caster_guid, spell_id, target_guid)?; begin_cast_with_admission( ctx, caster_guid, diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 1bfb933cf..b4c254b5b 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -25,26 +25,29 @@ pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { .game_pending_cast() .scheduled_id() .delete(sched.scheduled_id); - let result = resolve_cast_at_typed( - ctx, - sched.caster_guid, - sched.spell_id, - sched.level, - sched.target_guid, - // is_completion=true marks this as the TIMED cast finishing its bar (vs a genuine instant cast). The - // relay (subscriptions.rs on_cast) USES it: a timed completion sends GO ALONE (the begin already sent - // START(cast_time)); only a genuine instant (is_completion=false) sends the START(0)+GO pair. A 2nd - // START(0) at completion reset the cast bar to zero-length ("stuck on full") — the cast-lock bug. - true, - // NOT client-initiated (088): the completion GO must reach the caster via the relay — the - // synchronous send only covered the instant CMSG path, never a timed completion. - false, - // The clicked ground point carried across the cast bar (118 phase 2): a timed ground-AoE - // (Flamestrike) anchors its patch here at COMPLETION. `has_dest` false → None (every normal cast). - sched - .has_dest - .then_some((sched.dest_x, sched.dest_y, sched.dest_z)), - ); + let result = check_pending_cast_los(ctx, sched.caster_guid, sched.spell_id, sched.target_guid) + .and_then(|()| { + resolve_cast_at_typed( + ctx, + sched.caster_guid, + sched.spell_id, + sched.level, + sched.target_guid, + // is_completion=true marks this as the TIMED cast finishing its bar (vs a genuine instant cast). The + // relay (subscriptions.rs on_cast) USES it: a timed completion sends GO ALONE (the begin already sent + // START(cast_time)); only a genuine instant (is_completion=false) sends the START(0)+GO pair. A 2nd + // START(0) at completion reset the cast bar to zero-length ("stuck on full") — the cast-lock bug. + true, + // NOT client-initiated (088): the completion GO must reach the caster via the relay — the + // synchronous send only covered the instant CMSG path, never a timed completion. + false, + // The clicked ground point carried across the cast bar (118 phase 2): a timed ground-AoE + // (Flamestrike) anchors its patch here at COMPLETION. `has_dest` false → None (every normal cast). + sched + .has_dest + .then_some((sched.dest_x, sched.dest_y, sched.dest_z)), + ) + }); clear_dead_callback_cast_admission(ctx, sched.caster_guid, sched.spell_id); if let Err(e) = &result { log::info!( diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs new file mode 100644 index 000000000..2ce535c9d --- /dev/null +++ b/module/tests/playerbots_companion.rs @@ -0,0 +1,975 @@ +//! Durable Priest companion behavior against private, seeded Module databases. + +mod support; +use std::collections::BTreeMap; +use support::{poll_until, Standalone, POLL_TIMEOUT}; + +const HEAL: &str = "5090100"; +const CHANNEL_HEAL: &str = "5090104"; +const PB002_CORE: &str = "e6a755db0a150bbf73ad97b972fe829f20f6816c"; +const PB002_CORE_TREE: &str = "0769d6b7cd96d7e23a7ad16528399544ece0e2ec"; +const PB002_COLLECTION: &str = "155c9e401afb06d5731acedf8fc35a81dbe4aaa6"; +const PB002_COLLECTION_TREE: &str = "e2558a9cf421a74f79497ef361dbbfceb911a0d1"; +const PB002_PACKAGE_IDENTITY: &str = + "33fcb8a217aad84f46f9ad65efdad23be24bad8e29828777de7c7e6469417161"; + +fn git(path: &std::path::Path, args: &[&str]) -> String { + let result = std::process::Command::new("git") + .current_dir(path) + .args(args) + .output() + .unwrap(); + assert!(result.status.success()); + String::from_utf8(result.stdout).unwrap().trim().to_string() +} + +fn digest_files(path: &std::path::Path, digest: &mut blake3::Hasher) { + let mut children: Vec<_> = std::fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + children.sort(); + digest.update(&(children.len() as u64).to_le_bytes()); + for child in children { + let name = child.file_name().unwrap().as_encoded_bytes(); + digest.update(&(name.len() as u64).to_le_bytes()); + digest.update(name); + digest.update(&[u8::from(child.is_dir())]); + if child.is_dir() { + digest_files(&child, digest); + } else { + let contents = std::fs::read(&child).unwrap(); + digest.update(&(contents.len() as u64).to_le_bytes()); + digest.update(&contents); + } + } +} + +struct PrecedingPb002 { + wasm: Vec, + manifest: serde_json::Value, +} + +fn preceding_pb002() -> PrecedingPb002 { + let wasm_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_WASM") + .expect("PLAYERBOTS_COMPANION_PRECEDING_WASM must name the merged PB-002 Wasm"); + let manifest_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_MANIFEST") + .expect("PLAYERBOTS_COMPANION_PRECEDING_MANIFEST must describe that Wasm build"); + let core_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_CORE") + .expect("PLAYERBOTS_COMPANION_PRECEDING_CORE must name the clean PB-002 checkout"); + let collection_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_COLLECTION") + .expect("PLAYERBOTS_COMPANION_PRECEDING_COLLECTION must name the clean PB-002 checkout"); + let core_path = std::path::Path::new(&core_path); + let collection_path = std::path::Path::new(&collection_path); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(manifest_path).unwrap()).unwrap(); + let wasm = std::fs::read(&wasm_path).unwrap(); + + assert_eq!(manifest["core"], PB002_CORE); + assert_eq!(manifest["collection"], PB002_COLLECTION); + assert_eq!(manifest["core_tree"], PB002_CORE_TREE); + assert_eq!(manifest["collection_tree"], PB002_COLLECTION_TREE); + assert_eq!(manifest["core_dirty"], false); + assert_eq!(manifest["collection_dirty"], false); + assert_eq!(manifest["rust"], "1.93.0"); + assert_eq!(manifest["spacetimedb"], "2.7.1"); + assert_eq!(manifest["target"], "wasm32-unknown-unknown"); + assert_eq!(manifest["profile"], "release"); + assert_eq!(manifest["features"], serde_json::json!(["debug_reducers"])); + assert_eq!( + manifest["installed_packages"], + serde_json::json!(["dungeons", "example", "fire_nova", "playerbots"]) + ); + assert_eq!(manifest["package_content_identity"], PB002_PACKAGE_IDENTITY); + assert_eq!(manifest["wasm_bytes"].as_u64(), Some(wasm.len() as u64)); + + assert_eq!(git(core_path, &["rev-parse", "HEAD"]), PB002_CORE); + assert_eq!( + git(core_path, &["rev-parse", "HEAD^{tree}"]), + PB002_CORE_TREE + ); + assert!(git(core_path, &["status", "--porcelain"]).is_empty()); + assert_eq!( + git(collection_path, &["rev-parse", "HEAD"]), + PB002_COLLECTION + ); + assert_eq!( + git(collection_path, &["rev-parse", "HEAD^{tree}"]), + PB002_COLLECTION_TREE + ); + if let Some(playerbots_tree) = manifest["playerbots_tree"].as_str() { + assert_eq!( + git(collection_path, &["rev-parse", "HEAD:playerbots"]), + playerbots_tree + ); + } + assert!(git(collection_path, &["status", "--porcelain"]).is_empty()); + let mut package_digest = blake3::Hasher::new(); + digest_files(&collection_path.join("playerbots"), &mut package_digest); + assert_eq!( + package_digest.finalize().to_hex().as_str(), + PB002_PACKAGE_IDENTITY + ); + + let sha256 = std::process::Command::new("sha256sum") + .arg(&wasm_path) + .output() + .unwrap(); + assert!(sha256.status.success()); + let sha256 = String::from_utf8(sha256.stdout).unwrap(); + assert_eq!( + sha256.split_whitespace().next().unwrap(), + manifest["wasm_sha256"].as_str().unwrap() + ); + if let Some(expected) = manifest["wasm_blake3"].as_str() { + assert_eq!(blake3::hash(&wasm).to_hex().as_str(), expected); + } + + PrecedingPb002 { wasm, manifest } +} + +fn record_inputs(node: &Standalone) { + let core = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let package = core.join("packages/playerbots"); + let mut digest = blake3::Hasher::new(); + digest_files(&package, &mut digest); + let mut record: serde_json::Value = + serde_json::from_slice(&std::fs::read(package.join("fixtures/actions.json")).unwrap()) + .unwrap(); + record["tested_core"] = git(core, &["rev-parse", "HEAD"]).into(); + record["tested_collection"] = git(&package, &["rev-parse", "HEAD"]).into(); + record["core_dirty"] = (!git(core, &["status", "--porcelain"]).is_empty()).into(); + record["collection_dirty"] = (!git(&package, &["status", "--porcelain"]).is_empty()).into(); + record["package_content_identity"] = digest.finalize().to_hex().to_string().into(); + record["module_wasm_identity"] = blake3::hash(support::module_bytes()) + .to_hex() + .to_string() + .into(); + let path = support::log_dir().join(format!("{}-inputs.json", node.shard_name())); + std::fs::write(path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); +} + +fn select(node: &Standalone, bot: &str, mode: &str) { + node.assert_call( + "playerbots_select_controller", + &[bot, &format!("{{\"{mode}\":[]}}")], + ); +} + +fn runner(node: &Standalone, bot: &str) -> BTreeMap { + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_runner WHERE character_guid = {bot}" + )) + .into_iter() + .next() + .expect("runner explanation missing") +} + +fn position(node: &Standalone, guid: &str) -> (f32, f32) { + let row = &node.query_rows(&format!( + "SELECT x, y FROM game_world_entity WHERE guid = {guid}" + ))[0]; + (row["x"].parse().unwrap(), row["y"].parse().unwrap()) +} + +fn health(node: &Standalone, guid: &str) -> u32 { + node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {guid}" + ))[0]["health"] + .parse() + .unwrap() +} + +fn spline(node: &Standalone, guid: &str) -> Option> { + node.query_rows(&format!( + "SELECT start_micros, dur_ms, sx, sy, dx, dy, spline_id FROM game_creature_spline WHERE guid = {guid}" + )) + .into_iter() + .next() +} + +fn spline_finished(node: &Standalone, guid: &str, leg: &BTreeMap) -> bool { + let (x, y) = position(node, guid); + let at_destination = (x - leg["dx"].parse::().unwrap()).abs() < 0.01 + && (y - leg["dy"].parse::().unwrap()).abs() < 0.01; + at_destination + && spline(node, guid).is_none_or(|current| current["spline_id"] != leg["spline_id"]) +} + +fn fixture(name: &str) -> (Standalone, Vec) { + fixture_role(name, "1") +} + +fn fixture_role(name: &str, role: &str) -> (Standalone, Vec) { + let mut node = Standalone::start(name); + node.publish_module(); + record_inputs(&node); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + node.assert_call("playerbots_spawn_role", &["3", "1200", "1200", "50", role]); + node.assert_call("playerbots_fixture_prepare", &[]); + let bots: Vec<_> = node + .query_rows("SELECT character_guid FROM pkg_playerbots_bot") + .into_iter() + .map(|row| row["character_guid"].clone()) + .collect(); + node.assert_call( + "playerbots_fixture_companion_stage", + &[&bots[0], &bots[1], &bots[2]], + ); + (node, bots) +} + +fn due(node: &Standalone, guid: &str) { + node.assert_call("playerbots_fixture_companion_due", &[guid]); + node.assert_call("playerbots_fixture_runner_pass", &[]); +} + +fn pass_once(node: &Standalone, guid: &str) { + node.assert_call("playerbots_fixture_runner_pass_once", &[guid]); +} + +fn evidence(node: &Standalone, case: &str) { + let path = support::log_dir().join(format!("{}-{case}.json", node.shard_name())); + let record = serde_json::json!({ + "case": case, + "spacetimedb": "2.7.1", + "runner": node.query_rows("SELECT * FROM pkg_playerbots_runner"), + "actions": node.query_rows("SELECT * FROM pkg_playerbots_action"), + "entities": node.query_rows("SELECT guid, map_id, instance_id, x, y, z, health, max_health, dead FROM game_world_entity"), + "party": node.query_rows("SELECT * FROM game_group_member"), + "splines": node.query_rows("SELECT * FROM game_creature_spline"), + "pending_casts": node.query_rows("SELECT * FROM game_pending_cast"), + "cast_events": node.query_rows("SELECT * FROM game_spell_cast_event"), + "melee": node.query_rows("SELECT * FROM game_melee_attack"), + }); + std::fs::write(path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_priest_follows_a_moving_human_leader_without_pulling() { + let (node, bots) = fixture("playerbots-companion-follow"); + let (priest, leader) = (&bots[0], &bots[1]); + select(&node, priest, "cohort"); + assert!(poll_until(POLL_TIMEOUT, || runner(&node, priest)["chosen"] + .contains("follow"))); + let before = runner(&node, priest); + let objective = before["objective_sequence"].clone(); + assert!(before["objective"].contains("companion")); + assert!(before["companion_leader_guid"].contains(leader)); + assert!(before["foreground"].contains("movement")); + node.assert_call( + "playerbots_fixture_companion_move", + &[leader, "1240", "1200"], + ); + due(&node, priest); + let refreshed = runner(&node, priest); + assert_eq!(refreshed["objective_sequence"], objective); + assert!(refreshed["objective"].contains("x = 1240")); + assert!(poll_until(POLL_TIMEOUT, || position(&node, priest).0 > 1201.0)); + assert_eq!( + node.query_rows("SELECT guid FROM game_world_entity WHERE entry = 5090302") + .len(), + 1, + "the unrelated hostile fixture must be present" + ); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_melee_attack WHERE attacker_guid = {priest}" + )) + .is_empty()); + evidence(&node, "follow"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_missing_group_parent_holds_the_companion_objective() { + let (node, bots) = fixture("playerbots-companion-party-unavailable"); + let priest = &bots[0]; + select(&node, priest, "cohort"); + due(&node, priest); + let before = runner(&node, priest); + assert!(before["objective"].contains("companion")); + node.assert_call("playerbots_fixture_companion_remove_group", &[]); + due(&node, priest); + let held = runner(&node, priest); + assert_eq!(held["objective_sequence"], before["objective_sequence"]); + assert_eq!(held["objective"], before["objective"]); + assert!(held["chosen"].contains("partyUnavailable"), "{held:?}"); + assert!(held["last_outcome"].contains("partyFactsUnavailable")); + assert!(held["foreground"].contains("none")); + evidence(&node, "party-unavailable-hold"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_priest_retains_one_ally_cast_while_the_leader_moves_then_resumes_follow() { + let (node, bots) = fixture("playerbots-companion-heal"); + let (priest, leader, ally) = (&bots[0], &bots[1], &bots[2]); + select(&node, priest, "cohort"); + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + let ally_before = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {ally}" + ))[0]["health"] + .parse::() + .unwrap(); + due(&node, priest); + assert!(poll_until(POLL_TIMEOUT, || !node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + let pending = node.query_rows(&format!( + "SELECT scheduled_id, target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + ))[0] + .clone(); + assert_eq!(pending["target_guid"], *ally); + let starts = node.query_rows(&format!( + "SELECT * FROM game_spell_cast_event WHERE caster_guid = {priest} AND spell_id = {HEAL}" + )); + assert_eq!( + starts.iter().filter(|event| event["kind"] == "1").count(), + 1, + "{starts:?}" + ); + evidence(&node, "heal-started"); + let state = runner(&node, priest); + let objective = state["objective_sequence"].clone(); + assert!(state["chosen"].contains("heal")); + node.assert_call( + "playerbots_fixture_companion_move", + &[leader, "1240", "1200"], + ); + due(&node, priest); + assert_eq!(runner(&node, priest)["objective_sequence"], objective); + assert_eq!( + node.query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + ))[0]["scheduled_id"], + pending["scheduled_id"] + ); + evidence(&node, "heal-retained-before-completion"); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + let ally_after = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {ally}" + ))[0]["health"] + .parse::() + .unwrap(); + assert!(ally_after > ally_before); + due(&node, priest); + assert!(poll_until(POLL_TIMEOUT, || runner(&node, priest)["chosen"] + .contains("follow"))); + let events = node.query_rows(&format!( + "SELECT * FROM game_spell_cast_event WHERE caster_guid = {priest} AND spell_id = {HEAL}" + )); + assert_eq!( + events.iter().filter(|event| event["kind"] == "2").count(), + 1 + ); + assert_eq!(runner(&node, priest)["objective_sequence"], objective); + evidence(&node, "heal-resume"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_low_health_at_the_reached_leader_uses_recovery_instead_of_holding() { + let (node, bots) = fixture("playerbots-companion-safe-recovery"); + let (priest, leader) = (&bots[0], &bots[1]); + node.assert_call( + "playerbots_fixture_companion_move", + &[leader, "1202", "1200"], + ); + node.assert_call("playerbots_fixture_companion_health", &[priest, "25"]); + let before = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {priest}" + ))[0]["health"] + .parse::() + .unwrap(); + select(&node, priest, "cohort"); + due(&node, priest); + assert!(poll_until(POLL_TIMEOUT, || !node + .query_rows(&format!( + "SELECT target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + assert!(runner(&node, priest)["chosen"].contains("heal")); + assert!(!runner(&node, priest)["chosen"].contains("survival")); + assert_eq!( + node.query_rows(&format!( + "SELECT target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + ))[0]["target_guid"], + *priest + ); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + let after = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {priest}" + ))[0]["health"] + .parse::() + .unwrap(); + assert!(after > before); + evidence(&node, "safe-recovery"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_non_healer_companion_retains_self_recovery() { + let (node, bots) = fixture_role("playerbots-companion-non-healer-recovery", "2"); + let (companion, leader) = (&bots[0], &bots[1]); + node.assert_call( + "playerbots_fixture_companion_move", + &[leader, "1202", "1200"], + ); + node.assert_call("playerbots_fixture_companion_health", &[companion, "25"]); + select(&node, companion, "cohort"); + due(&node, companion); + let state = runner(&node, companion); + assert!(state["chosen"].contains("recovery"), "{state:?}"); + let pending = node.query_rows(&format!( + "SELECT spell_id, target_guid FROM game_pending_cast WHERE caster_guid = {companion}" + )); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0]["spell_id"], HEAL); + assert_eq!(pending[0]["target_guid"], *companion); + evidence(&node, "non-healer-recovery"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { + let (node, bots) = fixture("playerbots-companion-target-retention"); + let (priest, leader, ally) = (&bots[0], &bots[1], &bots[2]); + node.assert_call( + "playerbots_fixture_companion_move", + &[priest, "1340", "1200"], + ); + node.assert_call("playerbots_fixture_companion_health", &[leader, "40"]); + node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); + node.assert_call("playerbots_fixture_companion_health", &[ally, "30"]); + node.assert_call("playerbots_fixture_runner_select_cohort", &[priest]); + let mut movement_legs = 0; + let mut observed_splines = Vec::new(); + let mut previous_endpoint: Option<(f32, f32)> = None; + let mut previous_spline_id: Option = None; + let pending = loop { + pass_once(&node, priest); + let retained = runner(&node, priest); + assert!(retained["chosen"].contains(ally), "{retained:?}"); + assert!(retained["companion_heal_target_guid"].contains(ally)); + let pending = node.query_rows(&format!( + "SELECT scheduled_id, target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + )); + if let Some(pending) = pending.first() { + assert!(spline(&node, priest).is_none()); + assert_eq!(pending["target_guid"], *ally); + break pending.clone(); + } + assert!( + retained["chosen"].contains("castingPosition"), + "{retained:?}" + ); + let leg = spline(&node, priest).expect("casting-position pass did not start a spline"); + assert_ne!(leg["dur_ms"], "0"); + if let Some(previous) = &previous_spline_id { + assert_ne!(&leg["spline_id"], previous); + } + if let Some((x, y)) = previous_endpoint { + assert!((leg["sx"].parse::().unwrap() - x).abs() < 0.01); + assert!((leg["sy"].parse::().unwrap() - y).abs() < 0.01); + } + previous_endpoint = Some(( + leg["dx"].parse::().unwrap(), + leg["dy"].parse::().unwrap(), + )); + previous_spline_id = Some(leg["spline_id"].clone()); + observed_splines.push(leg.clone()); + evidence( + &node, + &format!("target-retention-leg-{}", movement_legs + 1), + ); + assert!(movement_legs < 6, "{retained:?}"); + assert!(poll_until(POLL_TIMEOUT, || spline_finished( + &node, priest, &leg + ))); + movement_legs += 1; + if movement_legs == 1 { + node.assert_call("playerbots_fixture_companion_health", &[leader, "10"]); + } + }; + assert!((2..=6).contains(&movement_legs)); + let path = support::log_dir().join(format!("{}-target-retention-legs.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "completed_legs": movement_legs, + "splines": &observed_splines, + "pending_cast": &pending, + })) + .unwrap(), + ) + .unwrap(); + + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + evidence(&node, "target-retention-cast-complete"); + + node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); + due(&node, priest); + let replaced = runner(&node, priest); + assert!(node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest} AND scheduled_id = {}", + pending["scheduled_id"] + )) + .is_empty()); + assert!(replaced["companion_heal_target_guid"].contains(leader)); + assert!(replaced["chosen"].contains(leader), "{replaced:?}"); + evidence(&node, "target-retention"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_unsupported_channel_does_not_hide_a_supported_learned_heal() { + let (node, bots) = fixture("playerbots-companion-mixed-heals"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("playerbots_fixture_companion_mixed_heals", &[priest]); + let bot = &node.query_rows(&format!( + "SELECT class, role FROM pkg_playerbots_bot WHERE character_guid = {priest}" + ))[0]; + for spell in [HEAL, CHANNEL_HEAL] { + let rotations = node.query_rows(&format!( + "SELECT class, role FROM pkg_playerbots_rotation WHERE spell_id = {spell}" + )); + assert_eq!(rotations.len(), 1, "{spell}: {rotations:?}"); + assert_eq!(rotations[0]["class"], bot["class"]); + assert_eq!(rotations[0]["role"], bot["role"]); + } + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + select(&node, priest, "cohort"); + due(&node, priest); + let state = runner(&node, priest); + assert!(state["chosen"].contains(HEAL), "{state:?}"); + assert!(!state["chosen"].contains(CHANNEL_HEAL), "{state:?}"); + let pending = node.query_rows(&format!( + "SELECT spell_id, target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + )); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0]["spell_id"], HEAL); + assert_eq!(pending[0]["target_guid"], *ally); + evidence(&node, "mixed-heal-capabilities"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_poor_range_selects_the_injured_allys_casting_position() { + let (node, bots) = fixture("playerbots-companion-range"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + select(&node, priest, "cohort"); + due(&node, priest); + let state = runner(&node, priest); + assert!(state["chosen"].contains("castingPosition"), "{state:?}"); + assert!(state["chosen"].contains(ally)); + evidence(&node, "range-prerequisite"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_targeted_los_gate_matches_client_and_bot_casts() { + let (node, bots) = fixture("playerbots-companion-los-parity"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("debug_set_nav_enabled", &["true"]); + node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); + node.assert_call("playerbots_fixture_cast", &[priest, ally]); + let bot_refusal = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + bot_refusal + .iter() + .any(|row| row["outcome"].contains("noLineOfSight")), + "{bot_refusal:?}" + ); + let client = node.call("playerbots_fixture_companion_client_cast", &[priest, ally]); + assert!(!client.status.success()); + let client_text = format!( + "{}{}", + String::from_utf8_lossy(&client.stdout), + String::from_utf8_lossy(&client.stderr) + ); + assert!(client_text.contains("line of sight"), "{client_text}"); + evidence(&node, "los-client-bot-parity"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_unlearned_actor_heal_refuses_without_cast_power_or_cooldown_state() { + let (node, bots) = fixture("playerbots-companion-unlearned"); + let (priest, ally) = (&bots[0], &bots[2]); + let power = node.query_rows(&format!( + "SELECT power FROM game_world_entity WHERE guid = {priest}" + ))[0]["power"] + .clone(); + node.assert_call("playerbots_fixture_companion_forget_heal", &[priest]); + node.assert_call("playerbots_fixture_cast", &[priest, ally]); + let action = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + action + .iter() + .any(|row| row["outcome"].contains("unlearnedSpell")), + "{action:?}" + ); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty()); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_spell_cd WHERE caster_guid = {priest} AND spell_id = {HEAL}" + )) + .is_empty()); + assert_eq!( + node.query_rows(&format!( + "SELECT power FROM game_world_entity WHERE guid = {priest}" + ))[0]["power"], + power + ); + evidence(&node, "unlearned-atomic-refusal"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_explicit_self_target_bypasses_targeted_los_and_completes() { + let (node, bots) = fixture("playerbots-companion-self-cast"); + let priest = &bots[0]; + node.assert_call("playerbots_fixture_companion_health", &[priest, "25"]); + let before = health(&node, priest); + node.assert_call( + "playerbots_fixture_companion_client_cast", + &[priest, priest], + ); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + assert!(health(&node, priest) > before); + evidence(&node, "self-target-complete"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_untargeted_actor_cast_keeps_its_supported_lifecycle() { + let (node, bots) = fixture("playerbots-companion-untargeted-cast"); + let priest = &bots[0]; + node.assert_call("playerbots_fixture_companion_client_cast", &[priest, "0"]); + let pending = node.query_rows(&format!( + "SELECT target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + )); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0]["target_guid"], "0"); + evidence(&node, "untargeted-started"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_triggered_player_cast_bypasses_targeted_los() { + let (node, bots) = fixture("playerbots-companion-triggered-cast"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("debug_set_nav_enabled", &["true"]); + node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); + let before = health(&node, ally); + node.assert_call( + "playerbots_fixture_companion_triggered_cast", + &[priest, ally], + ); + assert!(health(&node, ally) > before); + evidence(&node, "triggered-los-exemption"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_creature_cast_bypasses_targeted_los_and_completes() { + let (node, bots) = fixture("playerbots-companion-creature-cast"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("debug_set_nav_enabled", &["true"]); + node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); + let before = health(&node, ally); + node.assert_call("playerbots_fixture_companion_creature_cast", &[ally]); + let creature = ((0xF130u64 << 48) | (5_090_301u64 << 24) | 1).to_string(); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {creature}" + )) + .is_empty())); + assert!(health(&node, ally) > before); + evidence(&node, "creature-los-exemption"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_actor_channel_request_has_a_typed_unsupported_refusal() { + let (node, bots) = fixture("playerbots-companion-channel-refusal"); + let (priest, ally) = (&bots[0], &bots[2]); + node.assert_call("playerbots_fixture_cast_mode", &[priest, ally, "true"]); + let action = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + action + .iter() + .any(|row| row["outcome"].contains("unsupportedChannel")), + "{action:?}" + ); + evidence(&node, "channel-refusal"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_explicit_cancellation_releases_the_heal_and_resumes_follow() { + let (node, bots) = fixture("playerbots-companion-cancel-resume"); + let (priest, ally) = (&bots[0], &bots[2]); + select(&node, priest, "cohort"); + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + due(&node, priest); + assert!(poll_until(POLL_TIMEOUT, || !node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + let objective = runner(&node, priest)["objective_sequence"].clone(); + node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); + node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); + due(&node, priest); + assert_eq!(runner(&node, priest)["objective_sequence"], objective); + assert!(runner(&node, priest)["chosen"].contains("follow")); + assert!(runner(&node, priest)["companion_heal_target_guid"].contains("none")); + evidence(&node, "cancel-resume"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_completion_time_los_refusal_releases_the_heal_and_resumes_follow() { + let (node, bots) = fixture("playerbots-companion-los-resume"); + let (priest, ally) = (&bots[0], &bots[2]); + select(&node, priest, "cohort"); + node.assert_call("debug_set_nav_enabled", &["true"]); + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + due(&node, priest); + assert!(poll_until(POLL_TIMEOUT, || !node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + let objective = runner(&node, priest)["objective_sequence"].clone(); + node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )) + .iter() + .any(|row| row["outcome"].contains("noLineOfSight")))); + assert!(runner(&node, priest)["last_outcome"].contains("refused")); + assert!(runner(&node, priest)["companion_heal_target_guid"].contains("none")); + node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); + due(&node, priest); + assert_eq!(runner(&node, priest)["objective_sequence"], objective); + assert!(runner(&node, priest)["chosen"].contains("follow")); + evidence(&node, "los-refusal-resume"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_death_and_resurrection_preserve_role_then_regroup() { + let (node, bots) = fixture("playerbots-companion-death-regroup"); + let priest = &bots[0]; + select(&node, priest, "cohort"); + due(&node, priest); + let objective = runner(&node, priest)["objective_sequence"].clone(); + node.assert_call( + "playerbots_fixture_runner_damage", + &[priest, "0", "1000000"], + ); + due(&node, priest); + assert!(runner(&node, priest)["chosen"].contains("resurrection")); + evidence(&node, "dead-objective-retained"); + for _ in 0..3 { + due(&node, priest); + if node.query_rows(&format!( + "SELECT dead FROM game_world_entity WHERE guid = {priest}" + ))[0]["dead"] + == "false" + { + break; + } + } + assert_eq!( + node.query_rows(&format!( + "SELECT dead FROM game_world_entity WHERE guid = {priest}" + ))[0]["dead"], + "false" + ); + evidence(&node, "resurrected-before-regroup"); + for _ in 0..8 { + due(&node, priest); + let state = runner(&node, priest); + assert_eq!(state["objective_sequence"], objective); + if state["chosen"].contains("follow") { + break; + } + if !node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty() + { + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty())); + } + } + assert_eq!(runner(&node, priest)["objective_sequence"], objective); + assert!(runner(&node, priest)["companion_leader_guid"].contains(&bots[1])); + assert!(runner(&node, priest)["chosen"].contains("follow")); + let bot = node.query_rows(&format!( + "SELECT role FROM pkg_playerbots_bot WHERE character_guid = {priest}" + )); + assert_eq!(bot[0]["role"], "1"); + evidence(&node, "death-regroup"); +} + +#[test] +#[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn playerbots_populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() { + let preceding = preceding_pb002(); + let old_wasm = preceding.wasm; + assert_ne!( + blake3::hash(&old_wasm), + blake3::hash(support::module_bytes()) + ); + let mut node = Standalone::start("playerbots-companion-pb002-migration"); + node.publish_module_bytes(&old_wasm); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + node.assert_call("playerbots_spawn_role", &["1", "1200", "1200", "50", "1"]); + node.assert_call("playerbots_fixture_prepare", &[]); + node.assert_sql("UPDATE game_spell SET cast_time_ms = 60000 WHERE spell_id = 5090100"); + let bot = node.query_rows("SELECT character_guid FROM pkg_playerbots_bot")[0]["character_guid"] + .clone(); + node.assert_call("playerbots_fixture_runner_stage", &[&bot, "true"]); + select(&node, &bot, "cohort"); + assert!(poll_until(POLL_TIMEOUT, || { + runner(&node, &bot)["foreground"].contains("cast") + })); + for _ in 0..4 { + node.assert_call("playerbots_fixture_runner_damage", &[&bot, "0", "1"]); + } + node.assert_call("playerbots_fixture_freeze", &[&bot]); + let before = runner(&node, &bot); + let pending_before = node.query_rows(&format!( + "SELECT * FROM game_pending_cast WHERE caster_guid = {bot}" + )); + assert_eq!(pending_before.len(), 1); + assert!(before["objective"].contains("returnHome")); + assert!(before["foreground"].contains("cast")); + node.publish_module(); + record_inputs(&node); + let after = runner(&node, &bot); + let pending_after = node.query_rows(&format!( + "SELECT * FROM game_pending_cast WHERE caster_guid = {bot}" + )); + assert_eq!(after["objective_sequence"], before["objective_sequence"]); + assert_eq!(after["objective"], before["objective"]); + assert_eq!(after["foreground"], before["foreground"]); + assert_eq!(pending_after, pending_before); + assert!(after["companion_leader_guid"].contains("none")); + assert!(after["companion_heal_target_guid"].contains("none")); + let path = support::log_dir().join(format!("{}-migration.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "spacetimedb": "2.7.1", + "preceding_build": preceding.manifest, + "preceding_wasm_blake3": blake3::hash(&old_wasm).to_hex().to_string(), + "current_wasm_blake3": blake3::hash(support::module_bytes()).to_hex().to_string(), + "before": before, + "after": after, + "pending_before": pending_before, + "pending_after": pending_after, + })) + .unwrap(), + ) + .unwrap(); +} + +#[test] +#[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn playerbots_canonical_lesser_heal_repair_preserves_changed_definitions() { + let preceding = preceding_pb002(); + let old_wasm = preceding.wasm; + let mut node = Standalone::start("playerbots-companion-seed-repair"); + node.publish_module_bytes(&old_wasm); + node.assert_call("claim_operator", &[]); + node.publish_module(); + record_inputs(&node); + let effect_target = || { + node.query_rows("SELECT target FROM game_spell_effect WHERE id = 8200")[0]["target"].clone() + }; + assert_eq!(effect_target(), "0"); + node.assert_call( + "playerbots_fixture_companion_extra_lesser_heal_effect", + &["true"], + ); + node.assert_call("debug_repair_after_publish", &[]); + let target_with_extra_effect = effect_target(); + assert_eq!(target_with_extra_effect, "0"); + node.assert_call( + "playerbots_fixture_companion_extra_lesser_heal_effect", + &["false"], + ); + node.assert_call("debug_repair_after_publish", &[]); + let canonical_target = effect_target(); + assert_eq!(canonical_target, "2"); + node.assert_call("debug_repair_after_publish", &[]); + let repeated_target = effect_target(); + assert_eq!(repeated_target, "2"); + node.assert_call("playerbots_fixture_companion_lesser_heal_target", &["3"]); + node.assert_call("debug_repair_after_publish", &[]); + let changed_target = effect_target(); + assert_eq!(changed_target, "3"); + let path = support::log_dir().join(format!("{}-seed-repair.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "spacetimedb": "2.7.1", + "preceding_build": preceding.manifest, + "preceding_wasm_blake3": blake3::hash(&old_wasm).to_hex().to_string(), + "current_wasm_blake3": blake3::hash(support::module_bytes()).to_hex().to_string(), + "canonical_lesser_heal_target_after_repair": canonical_target, + "repeated_repair_target": repeated_target, + "additional_effect_preserved_legacy_target": target_with_extra_effect, + "changed_lesser_heal_target_after_repair": changed_target, + })) + .unwrap(), + ) + .unwrap(); +} diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index 0d638f664..a3576a160 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -733,6 +733,7 @@ fn playerbots_runner_recovery_revalidates_retained_rotation_and_spellbook_rows() runner_pass(&node); assert!(!runner(&node, &bot)["chosen"].contains("5090100")); node.assert_call("playerbots_fixture_runner_stage", &[&bot, "true"]); + node.assert_call("debug_learn_spell", &[&bot, "5090100"]); runner_pass(&node); runner_pass(&node); assert!(runner(&node, &bot)["chosen"].contains("5090100"));