From 194095faf0d6bd21e705d80272f86f39183e27ac Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:40:13 +0000 Subject: [PATCH 01/13] feat(spell): enforce companion cast gates --- CONTEXT.md | 4 +- docs/package-api.md | 15 +- module/src/actor.rs | 2 + module/src/debug/repair.rs | 3 + module/src/group.rs | 62 +++ module/src/gw.rs | 10 +- module/src/seed.rs | 72 +++- module/src/spell/cast/resolve.rs | 124 ++++++ module/src/spell/outcome.rs | 40 +- module/src/spell/scheduler.rs | 43 +- module/tests/playerbots_companion.rs | 605 +++++++++++++++++++++++++++ 11 files changed, 944 insertions(+), 36 deletions(-) create mode 100644 module/tests/playerbots_companion.rs 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..5eab85ef5 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,9 @@ 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. + ### 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/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..e221abf4b 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -472,6 +472,68 @@ 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, +} + +/// 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) -> Option { + let member = group_of(ctx, character_guid)?; + let group = ctx.db.game_group().group_id().find(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(); + 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..aa5a38b62 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,76 @@ 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 Some(mut effect) = effects.id().find(2050u64 << 2) else { + return 0; + }; + 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..e084c3192 --- /dev/null +++ b/module/tests/playerbots_companion.rs @@ -0,0 +1,605 @@ +//! 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"; + +fn record_inputs(node: &Standalone) { + let core = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let package = core.join("packages/playerbots"); + let git = |path: &std::path::Path, args: &[&str]| { + 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); + } + } + } + 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 fixture(name: &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", "1"]); + 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 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"), + "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 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 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 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 targeted_cast_gates_match_and_preserve_explicit_exemptions() { + let (node, bots) = fixture("playerbots-companion-gates"); + 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 range = runner(&node, priest); + assert!(range["chosen"].contains("castingPosition"), "{range:?}"); + assert!(range["chosen"].contains(ally)); + evidence(&node, "range-prerequisite"); + select(&node, priest, "frozen"); + + node.assert_call("debug_set_nav_enabled", &["true"]); + node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); + select(&node, priest, "cohort"); + due(&node, priest); + let state = runner(&node, priest); + assert!(state["chosen"].contains("castingPosition"), "{state:?}"); + assert!(state["chosen"].contains(ally)); + let movement = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + movement.iter().any(|row| { + row["outcome"].contains("blocked") || row["outcome"].contains("partial") + }), + "{movement:?}" + ); + evidence(&node, "los-prerequisite"); + select(&node, priest, "frozen"); + 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}"); + + let before = 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 unlearned = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + unlearned + .iter() + .any(|row| row["outcome"].contains("unlearnedSpell")), + "{unlearned:?}" + ); + 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"], + before + ); + + node.assert_call("playerbots_fixture_companion_learn_heal", &[priest]); + node.assert_call("playerbots_fixture_companion_health", &[priest, "25"]); + let self_before = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {priest}" + ))[0]["health"] + .parse::() + .unwrap(); + node.assert_call( + "playerbots_fixture_companion_client_cast", + &[priest, priest], + ); + 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())); + assert!( + node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {priest}" + ))[0]["health"] + .parse::() + .unwrap() + > self_before + ); + evidence(&node, "client-self-exemption-complete"); + std::thread::sleep(std::time::Duration::from_secs(2)); + node.assert_call("playerbots_fixture_companion_client_cast", &[priest, "0"]); + assert!(!node + .query_rows(&format!( + "SELECT * FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty()); + node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); + let triggered_before = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {ally}" + ))[0]["health"] + .parse::() + .unwrap(); + node.assert_call( + "playerbots_fixture_companion_triggered_cast", + &[priest, ally], + ); + let triggered_after = node.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {ally}" + ))[0]["health"] + .parse::() + .unwrap(); + assert!(triggered_after > triggered_before); + node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + 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 * FROM game_pending_cast WHERE caster_guid = {creature}" + )) + .is_empty())); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_spell_cast_event WHERE caster_guid = {creature} AND spell_id = {HEAL}" + )) + .iter() + .any(|event| event["kind"] == "2")); + node.assert_call("playerbots_fixture_cast_mode", &[priest, ally, "true"]); + let channel = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!( + channel + .iter() + .any(|row| row["outcome"].contains("unsupportedChannel")), + "{channel:?}" + ); + evidence(&node, "cast-gates"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn cancellation_and_death_preserve_the_companion_role_and_objective() { + let (node, bots) = fixture("playerbots-companion-recovery"); + 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!(runner(&node, priest)["chosen"].contains("follow")); + evidence(&node, "explicit-cancel-resume"); + 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())); + 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")); + node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); + due(&node, priest); + assert!(runner(&node, priest)["chosen"].contains("follow")); + 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, "cancel-death-regroup"); +} + +#[test] +#[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() { + let old_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_WASM") + .expect("PLAYERBOTS_COMPANION_PRECEDING_WASM must name the merged PB-002 Wasm"); + let old_wasm = std::fs::read(old_path).unwrap(); + assert_eq!( + blake3::hash(&old_wasm).to_hex().to_string(), + "e5bfa3e63cd2b03c782af829029030b9a6368b0e6552295958cc128613083fcb" + ); + 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", &[]); + 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); + assert!(before["objective"].contains("returnHome")); + assert!(before["foreground"].contains("cast")); + node.publish_module(); + record_inputs(&node); + let after = runner(&node, &bot); + assert_eq!(after["objective_sequence"], before["objective_sequence"]); + assert_eq!(after["objective"], before["objective"]); + assert_eq!(after["foreground"], before["foreground"]); + assert!(after["companion_leader_guid"].contains("none")); + 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("debug_repair_after_publish", &[]); + assert_eq!(effect_target(), "2"); + node.assert_call("debug_repair_after_publish", &[]); + assert_eq!(effect_target(), "2"); + node.assert_call("playerbots_fixture_companion_lesser_heal_target", &["3"]); + node.assert_call("debug_repair_after_publish", &[]); + assert_eq!(effect_target(), "3"); + 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_core": "e6a755db0a150bbf73ad97b972fe829f20f6816c", + "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", + "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": 2, + "imported_like_lesser_heal_target_after_repair": effect_target(), + "before": before, + "after": after, + })) + .unwrap(), + ) + .unwrap(); +} From bfb57b6cd5d04df702adb7cdba57ede439aca024 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:09:53 +0000 Subject: [PATCH 02/13] fix(companion): preserve party and spell invariants --- docs/package-api.md | 2 + module/src/group.rs | 27 +- module/src/seed.rs | 6 +- module/tests/playerbots_companion.rs | 357 ++++++++++++++++++++------- 4 files changed, 296 insertions(+), 96 deletions(-) diff --git a/docs/package-api.md b/docs/package-api.md index 5eab85ef5..b7ea7c180 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -132,6 +132,8 @@ caller's action deadline to have passed. They return whether they removed the ca `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 diff --git a/module/src/group.rs b/module/src/group.rs index e221abf4b..ced7fef9b 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -497,12 +497,29 @@ pub struct PartyFacts { 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) -> Option { - let member = group_of(ctx, character_guid)?; - let group = ctx.db.game_group().group_id().find(member.group_id)?; +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| { @@ -527,11 +544,11 @@ pub fn party_facts(ctx: &ReducerContext, character_guid: u64) -> Option u64 { return 0; }; let effects = ctx.db.game_spell_effect(); - let Some(mut effect) = effects.id().find(2050u64 << 2) else { + 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; } diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index e084c3192..9618378c6 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use support::{poll_until, Standalone, POLL_TIMEOUT}; const HEAL: &str = "5090100"; +const CHANNEL_HEAL: &str = "5090104"; fn record_inputs(node: &Standalone) { let core = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -82,6 +83,30 @@ fn position(node: &Standalone, guid: &str) -> (f32, f32) { (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 movement_leg_finished(node: &Standalone, guid: &str) -> bool { + let rows = node.query_rows(&format!( + "SELECT start_micros, dur_ms FROM game_creature_spline WHERE guid = {guid}" + )); + let Some(row) = rows.first() else { + return true; + }; + let finish = + row["start_micros"].parse::().unwrap() + row["dur_ms"].parse::().unwrap() * 1_000; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + now >= finish +} + fn fixture(name: &str) -> (Standalone, Vec) { let mut node = Standalone::start(name); node.publish_module(); @@ -159,6 +184,26 @@ fn priest_follows_a_moving_human_leader_without_pulling() { evidence(&node, "follow"); } +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 priest_retains_one_ally_cast_while_the_leader_moves_then_resumes_follow() { @@ -278,37 +323,85 @@ fn low_health_at_the_reached_leader_uses_recovery_instead_of_holding() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn targeted_cast_gates_match_and_preserve_explicit_exemptions() { - let (node, bots) = fixture("playerbots-companion-gates"); - let (priest, ally) = (&bots[0], &bots[2]); +fn 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_health", &[leader, "40"]); node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); + node.assert_call("playerbots_fixture_companion_health", &[ally, "30"]); + select(&node, priest, "cohort"); + due(&node, priest); + let first = runner(&node, priest); + assert!(first["chosen"].contains("castingPosition"), "{first:?}"); + assert!(first["chosen"].contains(ally)); + assert!(first["companion_heal_target_guid"].contains(ally)); + + node.assert_call("playerbots_fixture_companion_health", &[leader, "10"]); + for _ in 0..3 { + assert!(poll_until(POLL_TIMEOUT, || movement_leg_finished( + &node, priest + ))); + due(&node, priest); + let retained = runner(&node, priest); + assert!( + retained["chosen"].contains("castingPosition"), + "{retained:?}" + ); + assert!(retained["chosen"].contains(ally), "{retained:?}"); + assert!(retained["companion_heal_target_guid"].contains(ally)); + } + + node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); + due(&node, priest); + let replaced = runner(&node, priest); + 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 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]); node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); select(&node, priest, "cohort"); due(&node, priest); - let range = runner(&node, priest); - assert!(range["chosen"].contains("castingPosition"), "{range:?}"); - assert!(range["chosen"].contains(ally)); - evidence(&node, "range-prerequisite"); - select(&node, priest, "frozen"); + 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"); +} - node.assert_call("debug_set_nav_enabled", &["true"]); - node.assert_call("playerbots_fixture_companion_wall", &[priest, ally]); +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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)); - let movement = node.query_rows(&format!( - "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" - )); - assert!( - movement.iter().any(|row| { - row["outcome"].contains("blocked") || row["outcome"].contains("partial") - }), - "{movement:?}" - ); - evidence(&node, "los-prerequisite"); - select(&node, priest, "frozen"); + evidence(&node, "range-prerequisite"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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}" @@ -327,21 +420,28 @@ fn targeted_cast_gates_match_and_preserve_explicit_exemptions() { String::from_utf8_lossy(&client.stderr) ); assert!(client_text.contains("line of sight"), "{client_text}"); + evidence(&node, "los-client-bot-parity"); +} - let before = node.query_rows(&format!( +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 unlearned = node.query_rows(&format!( + let action = node.query_rows(&format!( "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" )); assert!( - unlearned + action .iter() .any(|row| row["outcome"].contains("unlearnedSpell")), - "{unlearned:?}" + "{action:?}" ); assert!(node .query_rows(&format!( @@ -357,94 +457,102 @@ fn targeted_cast_gates_match_and_preserve_explicit_exemptions() { node.query_rows(&format!( "SELECT power FROM game_world_entity WHERE guid = {priest}" ))[0]["power"], - before + power ); + evidence(&node, "unlearned-atomic-refusal"); +} - node.assert_call("playerbots_fixture_companion_learn_heal", &[priest]); +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 self_before = node.query_rows(&format!( - "SELECT health FROM game_world_entity WHERE guid = {priest}" - ))[0]["health"] - .parse::() - .unwrap(); + let before = health(&node, priest); node.assert_call( "playerbots_fixture_companion_client_cast", &[priest, priest], ); - 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())); - assert!( - node.query_rows(&format!( - "SELECT health FROM game_world_entity WHERE guid = {priest}" - ))[0]["health"] - .parse::() - .unwrap() - > self_before - ); - evidence(&node, "client-self-exemption-complete"); - std::thread::sleep(std::time::Duration::from_secs(2)); + assert!(health(&node, priest) > before); + evidence(&node, "self-target-complete"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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"]); - assert!(!node - .query_rows(&format!( - "SELECT * FROM game_pending_cast WHERE caster_guid = {priest}" - )) - .is_empty()); - node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); - let triggered_before = node.query_rows(&format!( - "SELECT health FROM game_world_entity WHERE guid = {ally}" - ))[0]["health"] - .parse::() - .unwrap(); + 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 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], ); - let triggered_after = node.query_rows(&format!( - "SELECT health FROM game_world_entity WHERE guid = {ally}" - ))[0]["health"] - .parse::() - .unwrap(); - assert!(triggered_after > triggered_before); - node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); + assert!(health(&node, ally) > before); + evidence(&node, "triggered-los-exemption"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 * FROM game_pending_cast WHERE caster_guid = {creature}" + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {creature}" )) .is_empty())); - assert!(node - .query_rows(&format!( - "SELECT * FROM game_spell_cast_event WHERE caster_guid = {creature} AND spell_id = {HEAL}" - )) - .iter() - .any(|event| event["kind"] == "2")); + assert!(health(&node, ally) > before); + evidence(&node, "creature-los-exemption"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 channel = node.query_rows(&format!( + let action = node.query_rows(&format!( "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" )); assert!( - channel + action .iter() .any(|row| row["outcome"].contains("unsupportedChannel")), - "{channel:?}" + "{action:?}" ); - evidence(&node, "cast-gates"); + evidence(&node, "channel-refusal"); } #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn cancellation_and_death_preserve_the_companion_role_and_objective() { - let (node, bots) = fixture("playerbots-companion-recovery"); +fn 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"]); @@ -458,8 +566,18 @@ fn cancellation_and_death_preserve_the_companion_role_and_objective() { 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")); - evidence(&node, "explicit-cancel-resume"); + assert!(runner(&node, priest)["companion_heal_target_guid"].contains("none")); + evidence(&node, "cancel-resume"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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); @@ -468,6 +586,7 @@ fn cancellation_and_death_preserve_the_companion_role_and_objective() { "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!( @@ -476,9 +595,22 @@ fn cancellation_and_death_preserve_the_companion_role_and_objective() { .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 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"], @@ -530,7 +662,7 @@ fn cancellation_and_death_preserve_the_companion_role_and_objective() { "SELECT role FROM pkg_playerbots_bot WHERE character_guid = {priest}" )); assert_eq!(bot[0]["role"], "1"); - evidence(&node, "cancel-death-regroup"); + evidence(&node, "death-regroup"); } #[test] @@ -574,18 +706,65 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() assert_eq!(after["objective"], before["objective"]); assert_eq!(after["foreground"], before["foreground"]); 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_core": "e6a755db0a150bbf73ad97b972fe829f20f6816c", + "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", + "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, + })) + .unwrap(), + ) + .unwrap(); +} + +#[test] +#[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn canonical_lesser_heal_repair_preserves_changed_definitions() { + let old_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_WASM") + .expect("PLAYERBOTS_COMPANION_PRECEDING_WASM must name the merged PB-002 Wasm"); + let old_wasm = std::fs::read(old_path).unwrap(); + assert_eq!( + blake3::hash(&old_wasm).to_hex().to_string(), + "e5bfa3e63cd2b03c782af829029030b9a6368b0e6552295958cc128613083fcb" + ); + 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", &[]); - assert_eq!(effect_target(), "2"); + 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", &[]); - assert_eq!(effect_target(), "2"); + 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", &[]); - assert_eq!(effect_target(), "3"); - let path = support::log_dir().join(format!("{}-migration.json", node.shard_name())); + 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!({ @@ -594,10 +773,10 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", "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": 2, - "imported_like_lesser_heal_target_after_repair": effect_target(), - "before": before, - "after": after, + "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(), ) From d3f5398372e91d990fb9a16671c18829e9441c21 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:42:16 +0000 Subject: [PATCH 03/13] fix(debug): preserve staged spell cast contract --- module/src/debug/mod.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 70ccc2607..66bb4258d 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1038,8 +1038,18 @@ 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. + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + crate::spell::resolve_cast_at( + ctx, + caster_guid, + spell_id, + caster.level as u8, + target_guid, + false, + false, + None, + ) } /// Begin a (possibly cast-timed) cast from `caster_guid` AT `target_guid` — drives `begin_cast` so a From 5433d21e3eaeaefccbc7f9921bddd2bc3b3c85a8 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:52:42 +0000 Subject: [PATCH 04/13] test(playerbots): verify preceding build provenance --- module/tests/playerbots_companion.rs | 204 +++++++++++++++++++-------- 1 file changed, 143 insertions(+), 61 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 9618378c6..11b2e6997 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -2,46 +2,137 @@ mod support; use std::collections::BTreeMap; -use support::{poll_until, Standalone, POLL_TIMEOUT}; +use support::{POLL_TIMEOUT, Standalone, poll_until}; 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 git = |path: &std::path::Path, args: &[&str]| { - 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); - } - } - } let mut digest = blake3::Hasher::new(); digest_files(&package, &mut digest); let mut record: serde_json::Value = @@ -176,11 +267,12 @@ fn priest_follows_a_moving_human_leader_without_pulling() { 1, "the unrelated hostile fixture must be present" ); - assert!(node - .query_rows(&format!( + assert!( + node.query_rows(&format!( "SELECT * FROM game_melee_attack WHERE attacker_guid = {priest}" )) - .is_empty()); + .is_empty() + ); evidence(&node, "follow"); } @@ -443,16 +535,18 @@ fn unlearned_actor_heal_refuses_without_cast_power_or_cooldown_state() { .any(|row| row["outcome"].contains("unlearnedSpell")), "{action:?}" ); - assert!(node - .query_rows(&format!( + assert!( + node.query_rows(&format!( "SELECT * FROM game_pending_cast WHERE caster_guid = {priest}" )) - .is_empty()); - assert!(node - .query_rows(&format!( + .is_empty() + ); + assert!( + node.query_rows(&format!( "SELECT * FROM game_spell_cd WHERE caster_guid = {priest} AND spell_id = {HEAL}" )) - .is_empty()); + .is_empty() + ); assert_eq!( node.query_rows(&format!( "SELECT power FROM game_world_entity WHERE guid = {priest}" @@ -668,13 +762,8 @@ fn death_and_resurrection_preserve_role_then_regroup() { #[test] #[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() { - let old_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_WASM") - .expect("PLAYERBOTS_COMPANION_PRECEDING_WASM must name the merged PB-002 Wasm"); - let old_wasm = std::fs::read(old_path).unwrap(); - assert_eq!( - blake3::hash(&old_wasm).to_hex().to_string(), - "e5bfa3e63cd2b03c782af829029030b9a6368b0e6552295958cc128613083fcb" - ); + let preceding = preceding_pb002(); + let old_wasm = preceding.wasm; assert_ne!( blake3::hash(&old_wasm), blake3::hash(support::module_bytes()) @@ -689,9 +778,9 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() .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"))); + 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"]); } @@ -712,8 +801,7 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() path, serde_json::to_vec_pretty(&serde_json::json!({ "spacetimedb": "2.7.1", - "preceding_core": "e6a755db0a150bbf73ad97b972fe829f20f6816c", - "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", + "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, @@ -727,13 +815,8 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() #[test] #[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] fn canonical_lesser_heal_repair_preserves_changed_definitions() { - let old_path = std::env::var_os("PLAYERBOTS_COMPANION_PRECEDING_WASM") - .expect("PLAYERBOTS_COMPANION_PRECEDING_WASM must name the merged PB-002 Wasm"); - let old_wasm = std::fs::read(old_path).unwrap(); - assert_eq!( - blake3::hash(&old_wasm).to_hex().to_string(), - "e5bfa3e63cd2b03c782af829029030b9a6368b0e6552295958cc128613083fcb" - ); + 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", &[]); @@ -769,8 +852,7 @@ fn canonical_lesser_heal_repair_preserves_changed_definitions() { path, serde_json::to_vec_pretty(&serde_json::json!({ "spacetimedb": "2.7.1", - "preceding_core": "e6a755db0a150bbf73ad97b972fe829f20f6816c", - "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", + "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, From 5be8ed0b0db6893e9df9655053f4f4a3e8b3ca03 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:01:40 +0000 Subject: [PATCH 05/13] test(playerbots): cover grouped self recovery --- module/tests/playerbots_companion.rs | 52 ++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 11b2e6997..a355a2719 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -2,7 +2,7 @@ mod support; use std::collections::BTreeMap; -use support::{POLL_TIMEOUT, Standalone, poll_until}; +use support::{poll_until, Standalone, POLL_TIMEOUT}; const HEAL: &str = "5090100"; const CHANNEL_HEAL: &str = "5090104"; @@ -199,12 +199,16 @@ fn movement_leg_finished(node: &Standalone, guid: &str) -> bool { } 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", "1"]); + 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") @@ -267,12 +271,11 @@ fn priest_follows_a_moving_human_leader_without_pulling() { 1, "the unrelated hostile fixture must be present" ); - assert!( - node.query_rows(&format!( + assert!(node + .query_rows(&format!( "SELECT * FROM game_melee_attack WHERE attacker_guid = {priest}" )) - .is_empty() - ); + .is_empty()); evidence(&node, "follow"); } @@ -413,6 +416,29 @@ fn low_health_at_the_reached_leader_uses_recovery_instead_of_holding() { evidence(&node, "safe-recovery"); } +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn 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 casting_position_retains_one_injured_ally_across_movement_legs() { @@ -535,18 +561,16 @@ fn unlearned_actor_heal_refuses_without_cast_power_or_cooldown_state() { .any(|row| row["outcome"].contains("unlearnedSpell")), "{action:?}" ); - assert!( - node.query_rows(&format!( + assert!(node + .query_rows(&format!( "SELECT * FROM game_pending_cast WHERE caster_guid = {priest}" )) - .is_empty() - ); - assert!( - node.query_rows(&format!( + .is_empty()); + assert!(node + .query_rows(&format!( "SELECT * FROM game_spell_cd WHERE caster_guid = {priest} AND spell_id = {HEAL}" )) - .is_empty() - ); + .is_empty()); assert_eq!( node.query_rows(&format!( "SELECT power FROM game_world_entity WHERE guid = {priest}" From fee614ecbad5cc512a9e1c2d7f8a1e830c74816e Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:02:53 +0000 Subject: [PATCH 06/13] test(playerbots): bind mixed heals to companion role --- module/tests/playerbots_companion.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index a355a2719..d7be35aaf 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -483,6 +483,17 @@ fn 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); From 4b87f0a44c2fa4f08b8bcb05743b59e94136a0a6 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:16:50 +0000 Subject: [PATCH 07/13] test(playerbots): keep Package cases out of core CI --- module/tests/playerbots_companion.rs | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index d7be35aaf..775358f63 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -245,7 +245,7 @@ fn evidence(node: &Standalone, case: &str) { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn priest_follows_a_moving_human_leader_without_pulling() { +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"); @@ -281,7 +281,7 @@ fn priest_follows_a_moving_human_leader_without_pulling() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn missing_group_parent_holds_the_companion_objective() { +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"); @@ -301,7 +301,7 @@ fn missing_group_parent_holds_the_companion_objective() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn priest_retains_one_ally_cast_while_the_leader_moves_then_resumes_follow() { +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"); @@ -374,7 +374,7 @@ fn priest_retains_one_ally_cast_while_the_leader_moves_then_resumes_follow() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn low_health_at_the_reached_leader_uses_recovery_instead_of_holding() { +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( @@ -418,7 +418,7 @@ fn low_health_at_the_reached_leader_uses_recovery_instead_of_holding() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn non_healer_companion_retains_self_recovery() { +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( @@ -441,7 +441,7 @@ fn non_healer_companion_retains_self_recovery() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn casting_position_retains_one_injured_ally_across_movement_legs() { +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_health", &[leader, "40"]); @@ -479,7 +479,7 @@ fn casting_position_retains_one_injured_ally_across_movement_legs() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn unsupported_channel_does_not_hide_a_supported_learned_heal() { +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]); @@ -511,7 +511,7 @@ fn unsupported_channel_does_not_hide_a_supported_learned_heal() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn poor_range_selects_the_injured_allys_casting_position() { +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"]); @@ -526,7 +526,7 @@ fn poor_range_selects_the_injured_allys_casting_position() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn targeted_los_gate_matches_client_and_bot_casts() { +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"]); @@ -554,7 +554,7 @@ fn targeted_los_gate_matches_client_and_bot_casts() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn unlearned_actor_heal_refuses_without_cast_power_or_cooldown_state() { +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!( @@ -593,7 +593,7 @@ fn unlearned_actor_heal_refuses_without_cast_power_or_cooldown_state() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn explicit_self_target_bypasses_targeted_los_and_completes() { +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"]); @@ -613,7 +613,7 @@ fn explicit_self_target_bypasses_targeted_los_and_completes() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn untargeted_actor_cast_keeps_its_supported_lifecycle() { +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"]); @@ -627,7 +627,7 @@ fn untargeted_actor_cast_keeps_its_supported_lifecycle() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn triggered_player_cast_bypasses_targeted_los() { +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"]); @@ -643,7 +643,7 @@ fn triggered_player_cast_bypasses_targeted_los() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn creature_cast_bypasses_targeted_los_and_completes() { +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"]); @@ -662,7 +662,7 @@ fn creature_cast_bypasses_targeted_los_and_completes() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn actor_channel_request_has_a_typed_unsupported_refusal() { +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"]); @@ -680,7 +680,7 @@ fn actor_channel_request_has_a_typed_unsupported_refusal() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn explicit_cancellation_releases_the_heal_and_resumes_follow() { +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"); @@ -703,7 +703,7 @@ fn explicit_cancellation_releases_the_heal_and_resumes_follow() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn completion_time_los_refusal_releases_the_heal_and_resumes_follow() { +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"); @@ -734,7 +734,7 @@ fn completion_time_los_refusal_releases_the_heal_and_resumes_follow() { #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn death_and_resurrection_preserve_role_then_regroup() { +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"); @@ -796,7 +796,7 @@ fn death_and_resurrection_preserve_role_then_regroup() { #[test] #[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] -fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() { +fn playerbots_populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() { let preceding = preceding_pb002(); let old_wasm = preceding.wasm; assert_ne!( @@ -849,7 +849,7 @@ fn populated_pb002_runner_state_upgrades_with_objective_and_foreground_intact() #[test] #[ignore = "requires the pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] -fn canonical_lesser_heal_repair_preserves_changed_definitions() { +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"); From caaf4f1ff8348495958c7b4f06d6ca5f327def00 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:28:45 +0000 Subject: [PATCH 08/13] fix(debug): keep forced cast synchronous --- module/src/debug/mod.rs | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 66bb4258d..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 @@ -1039,17 +1059,7 @@ pub fn debug_cast_at( ) -> Result<(), String> { // Debug direct cast resolves synchronously — not a timed-cast completion (instant packet // sequence). It bypasses player spellbook admission so fixtures can cast rows they just staged. - let caster = crate::helpers::live_entity(ctx, caster_guid)?; - crate::spell::resolve_cast_at( - ctx, - caster_guid, - spell_id, - caster.level as u8, - target_guid, - false, - false, - None, - ) + 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 From 2436053a820015f6eb21a4d70e7b2bd055c4d4b6 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:39:09 +0000 Subject: [PATCH 09/13] test(playerbots): observe retained target transitions --- module/tests/playerbots_companion.rs | 42 +++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 775358f63..68054c988 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -445,7 +445,7 @@ 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_health", &[leader, "40"]); - node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); + node.assert_call("playerbots_fixture_companion_move", &[ally, "1600", "1200"]); node.assert_call("playerbots_fixture_companion_health", &[ally, "30"]); select(&node, priest, "cohort"); due(&node, priest); @@ -453,25 +453,53 @@ fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { assert!(first["chosen"].contains("castingPosition"), "{first:?}"); assert!(first["chosen"].contains(ally)); assert!(first["companion_heal_target_guid"].contains(ally)); + evidence(&node, "target-retention-first-leg"); node.assert_call("playerbots_fixture_companion_health", &[leader, "10"]); - for _ in 0..3 { + let mut movement_legs = 1; + let pending = loop { assert!(poll_until(POLL_TIMEOUT, || movement_leg_finished( &node, priest ))); due(&node, priest); let retained = runner(&node, priest); - assert!( - retained["chosen"].contains("castingPosition"), - "{retained:?}" - ); assert!(retained["chosen"].contains(ally), "{retained:?}"); assert!(retained["companion_heal_target_guid"].contains(ally)); - } + evidence( + &node, + &format!("target-retention-transition-{movement_legs}"), + ); + if retained["chosen"].contains("castingPosition") { + movement_legs += 1; + assert!(movement_legs <= 6, "{retained:?}"); + continue; + } + assert!(retained["chosen"].contains("heal"), "{retained:?}"); + let pending = node.query_rows(&format!( + "SELECT scheduled_id, target_guid FROM game_pending_cast WHERE caster_guid = {priest}" + )); + assert_eq!(pending.len(), 1, "{retained:?}"); + assert_eq!(pending[0]["target_guid"], *ally); + break pending[0].clone(); + }; + assert!(movement_legs >= 2); + + 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"); From 0db3ac48b0fa69d6c4381e66f045a425909b839d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:02:29 +0000 Subject: [PATCH 10/13] test(playerbots): keep fixture spell admission explicit --- module/tests/playerbots_companion.rs | 2 +- module/tests/playerbots_rewrite/runner.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 68054c988..c2c6ddc06 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -445,7 +445,7 @@ 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_health", &[leader, "40"]); - node.assert_call("playerbots_fixture_companion_move", &[ally, "1600", "1200"]); + node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); node.assert_call("playerbots_fixture_companion_health", &[ally, "30"]); select(&node, priest, "cohort"); due(&node, priest); 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")); From 8306ccb4c5951fe4c39dee69717e446ad8af0bde Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:16:05 +0000 Subject: [PATCH 11/13] test(playerbots): count observed companion movement legs --- module/tests/playerbots_companion.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index c2c6ddc06..8d21e3687 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -448,6 +448,7 @@ fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { node.assert_call("playerbots_fixture_companion_move", &[ally, "1400", "1200"]); node.assert_call("playerbots_fixture_companion_health", &[ally, "30"]); select(&node, priest, "cohort"); + let mut leg_start = position(&node, priest); due(&node, priest); let first = runner(&node, priest); assert!(first["chosen"].contains("castingPosition"), "{first:?}"); @@ -456,11 +457,15 @@ fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { evidence(&node, "target-retention-first-leg"); node.assert_call("playerbots_fixture_companion_health", &[leader, "10"]); - let mut movement_legs = 1; + let mut movement_legs = 0; let pending = loop { - assert!(poll_until(POLL_TIMEOUT, || movement_leg_finished( - &node, priest - ))); + assert!(poll_until(POLL_TIMEOUT, || { + let observed = position(&node, priest); + ((observed.0 - leg_start.0).abs() > 0.1 || (observed.1 - leg_start.1).abs() > 0.1) + && movement_leg_finished(&node, priest) + })); + leg_start = position(&node, priest); + movement_legs += 1; due(&node, priest); let retained = runner(&node, priest); assert!(retained["chosen"].contains(ally), "{retained:?}"); @@ -470,8 +475,7 @@ fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { &format!("target-retention-transition-{movement_legs}"), ); if retained["chosen"].contains("castingPosition") { - movement_legs += 1; - assert!(movement_legs <= 6, "{retained:?}"); + assert!(movement_legs < 6, "{retained:?}"); continue; } assert!(retained["chosen"].contains("heal"), "{retained:?}"); From b3bf5a7e91536c7dc2f7cfb90305e6d1f35889f6 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:47:17 +0000 Subject: [PATCH 12/13] test(playerbots): observe exact companion movement legs --- module/tests/playerbots_companion.rs | 128 ++++++++++++++++++--------- 1 file changed, 85 insertions(+), 43 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 8d21e3687..6688e2f53 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -182,20 +182,20 @@ fn health(node: &Standalone, guid: &str) -> u32 { .unwrap() } -fn movement_leg_finished(node: &Standalone, guid: &str) -> bool { - let rows = node.query_rows(&format!( - "SELECT start_micros, dur_ms FROM game_creature_spline WHERE guid = {guid}" - )); - let Some(row) = rows.first() else { - return true; - }; - let finish = - row["start_micros"].parse::().unwrap() + row["dur_ms"].parse::().unwrap() * 1_000; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_micros() as u64; - now >= finish +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) { @@ -227,6 +227,10 @@ fn due(node: &Standalone, guid: &str) { 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!({ @@ -236,6 +240,7 @@ fn evidence(node: &Standalone, case: &str) { "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"), @@ -444,49 +449,75 @@ fn playerbots_non_healer_companion_retains_self_recovery() { 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"]); select(&node, priest, "cohort"); - let mut leg_start = position(&node, priest); - due(&node, priest); - let first = runner(&node, priest); - assert!(first["chosen"].contains("castingPosition"), "{first:?}"); - assert!(first["chosen"].contains(ally)); - assert!(first["companion_heal_target_guid"].contains(ally)); - evidence(&node, "target-retention-first-leg"); - - node.assert_call("playerbots_fixture_companion_health", &[leader, "10"]); 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 { - assert!(poll_until(POLL_TIMEOUT, || { - let observed = position(&node, priest); - ((observed.0 - leg_start.0).abs() > 0.1 || (observed.1 - leg_start.1).abs() > 0.1) - && movement_leg_finished(&node, priest) - })); - leg_start = position(&node, priest); - movement_legs += 1; - due(&node, priest); + 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-transition-{movement_legs}"), + &format!("target-retention-leg-{}", movement_legs + 1), ); - if retained["chosen"].contains("castingPosition") { - assert!(movement_legs < 6, "{retained:?}"); - continue; + 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!(retained["chosen"].contains("heal"), "{retained:?}"); - let pending = node.query_rows(&format!( - "SELECT scheduled_id, target_guid FROM game_pending_cast WHERE caster_guid = {priest}" - )); - assert_eq!(pending.len(), 1, "{retained:?}"); - assert_eq!(pending[0]["target_guid"], *ally); - break pending[0].clone(); }; - assert!(movement_legs >= 2); + 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!( @@ -841,6 +872,7 @@ fn playerbots_populated_pb002_runner_state_upgrades_with_objective_and_foregroun 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"]); @@ -853,14 +885,22 @@ fn playerbots_populated_pb002_runner_state_upgrades_with_objective_and_foregroun } 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())); @@ -873,6 +913,8 @@ fn playerbots_populated_pb002_runner_state_upgrades_with_objective_and_foregroun "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(), ) From 624edcdfde9c607a22c9ddc751b7c40caf6c7018 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:15:39 +0000 Subject: [PATCH 13/13] test(playerbots): close companion selection race --- module/tests/playerbots_companion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 6688e2f53..2ce535c9d 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -456,7 +456,7 @@ fn playerbots_casting_position_retains_one_injured_ally_across_movement_legs() { 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"]); - select(&node, priest, "cohort"); + 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;