From a638b2ce9869093228531d324746fdea527d18f5 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:40:20 +0000 Subject: [PATCH 01/16] feat(playerbots): add priest companion behavior --- .github/workflows/core-tip.yml | 33 +++- playerbots/README.md | 40 +++- playerbots/fixtures/actions.json | 5 +- playerbots/src/companion.rs | 202 +++++++++++++++++++ playerbots/src/decision.rs | 6 + playerbots/src/fixture.rs | 328 ++++++++++++++++++++++++++++++- playerbots/src/mod.rs | 1 + playerbots/src/runner.rs | 254 +++++++++++++++++------- 8 files changed, 789 insertions(+), 80 deletions(-) create mode 100644 playerbots/src/companion.rs diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 4ba5522..1460cf8 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -120,6 +120,10 @@ jobs: RUSTUP_TOOLCHAIN: 1.93.0 SPACETIME_VERSION: 2.7.1 SPACETIME_ARCHIVE_SHA256: 9b58192135733083479f30aba47dbbbc79b51db368c46cc221baa663e9c3a2c1 + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 steps: - name: Check out Packages uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -179,12 +183,39 @@ jobs: --locked --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers cp "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/lyracore_module.wasm" "$RUNNER_TEMP/playerbots-preceding.wasm" echo "PLAYERBOTS_PRECEDING_WASM=$RUNNER_TEMP/playerbots-preceding.wasm" >> "$GITHUB_ENV" + - name: Check out the merged PB-002 Module source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LyraCoreProject/LyraCore + ref: e6a755db0a150bbf73ad97b972fe829f20f6816c + path: preceding-pb002-core + persist-credentials: false + - name: Check out the merged PB-002 Package source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LyraCoreProject/packages + ref: 155c9e401afb06d5731acedf8fc35a81dbe4aaa6 + path: preceding-pb002-collection + persist-credentials: false + - name: Build the merged PB-002 Wasm for populated companion migration + working-directory: preceding-pb002-core + run: | + export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/preceding-pb002-core/target" + cargo clean --manifest-path "$GITHUB_WORKSPACE/preceding-pb002-core/Cargo.toml" -p lyracore-module + "$GITHUB_WORKSPACE/preceding-pb002-collection/.github/check-core-tip.sh" "$GITHUB_WORKSPACE/preceding-pb002-core" \ + cargo build --manifest-path "$GITHUB_WORKSPACE/preceding-pb002-core/Cargo.toml" \ + --locked --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers + cp "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/lyracore_module.wasm" "$RUNNER_TEMP/playerbots-pb002-preceding.wasm" + echo "PLAYERBOTS_COMPANION_PRECEDING_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" - name: Run the real Module and Package action fixture + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/lyracore/target run: | cargo clean --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" -p lyracore-module ./collection/.github/check-core-tip.sh "$GITHUB_WORKSPACE/lyracore" \ cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ - -p lyracore-module --test playerbots_rewrite -- --ignored --test-threads=1 + -p lyracore-module --test playerbots_rewrite --test playerbots_companion \ + -- --ignored --test-threads=1 - name: Upload fixture inputs and Standalone logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/playerbots/README.md b/playerbots/README.md index 1bd8a21..8ce295b 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -421,10 +421,20 @@ the Package cast identity or movement start time, so it preserves a human's late can resume after ownership ends. The ownership Gate permits an absent body for Legacy restoration; group admission additionally requires a live entity. -The Cohort behavior in this revision returns to the roster's home point. It retains that objective -while healing itself with a known supported rotation spell, defending against damage, or fleeing -at its configured health threshold. Companion orders and quest catalog execution remain separate -work. The existing Legacy policy remains available under its explicit selector. +The Cohort behavior follows the durable leader of a human-led party. It refreshes the leader's +position without replacing the companion objective, so a moving leader cannot restart a retained +cast. An injured same-partition party member outranks follow when the Priest knows a supported +healing rotation spell. Range or line of sight becomes a movement prerequisite that retains the +member identity. A blocked route remains visible as a CastingPosition wait. A completed, cancelled, +or refused cast releases the foreground action so the next pass can heal again or resume follow. +An ungrouped Cohort returns to its roster home point. A bot-led party does not activate companion +control. The existing Legacy policy remains available under its explicit selector. + +A dead companion retains its role and objective while it releases and uses the spirit healer. Once +alive, it regroups with the same leader. Survival movement has priority over healing while the bot +is away from a safe destination. At a reached destination, survival is complete and usable recovery +can run. If recovery is missing, the runner leaves lower-priority maintenance reachable instead of +hiding it behind a Survival Hold. Recovery scans at most 24 indexed healing rotation rows and revalidates at most two retained rows per pass. The separate @@ -452,14 +462,16 @@ One foreground action retains the controller generation and partition. A higher- cancels incompatible work before starting. Pending casts retain the core scheduled identity and refresh its current due time after direct-damage pushback. After a movement request, arrival requires a later position observation. Ten seconds without movement records a failure; three failed intervals defer the -same destination for 30 seconds. Deferred keys include map, instance and optional navigation -coverage generation. A coverage change invalidates the prior destination decision. +same return-home destination for 30 seconds. Companion movement keeps the leader identity and +retries from current durable party facts instead. Deferred keys include map, instance and optional +navigation coverage generation. A coverage change invalidates the prior destination decision. The selector allows at most 24 candidates, depth four, 16 transitions and one route request with 4096 expansions per decision. These limits also apply to prerequisites, alternatives and continuers. Strategies use typed triggers and integer priority adjustments. Each Candidate carries a typed action -payload, reason and objective identity. Movement names Home or an Entity; Cast carries its spell and -target; Attack carries its target. There is no string registry. +payload, reason and objective identity. Movement names Home, an Entity, or an ally whose casting +position is required. Cast carries its spell and target; Attack carries its target. There is no +string registry. The migration appends `controller = Legacy` and `scheduler_lag_micros = 0` to the roster and adds runner, recovery scan and scheduler tables. Existing goals and action observations keep their schema and meaning. @@ -467,6 +479,18 @@ The roster selector travels with the Character; runner observations and foregrou The Character delete operation removes the runner and recovery scan rows. Production publication still requires the schema review described in LyraCore's `docs/danger-zones.md`. +The companion migration appends a nullable leader identity to each runner row. Its populated upgrade +case starts from merged PB-002 core `e6a755db0a150bbf73ad97b972fe829f20f6816c` and collection +`155c9e401afb06d5731acedf8fc35a81dbe4aaa6`. It publishes a retained return-home objective and cast +foreground, upgrades the same private Standalone, and checks that both values survive while the new +leader field receives its null default. Set `PLAYERBOTS_COMPANION_PRECEDING_WASM` to that immutable +Wasm when running the companion target outside CI. + +Fresh Shards seed Lesser Heal 2050 as a heal for a named ally. Existing Shards keep their stored +spell definitions when the Module is published, so the normal `debug_repair_after_publish` step +updates only the complete former canonical seed shape. Imported or tuned 2050 rows remain unchanged; +merging or publishing the source without that repair step does not change them. + The durable fixture now includes populated migration. Before running the complete ignored target, build Module Wasm from core `be3fa67d0f0c24749230560544a3e8e8b577f61d` with collection `5724de1660a2628e88320914bdd5abb0c69da517`, retain an immutable copy, and set diff --git a/playerbots/fixtures/actions.json b/playerbots/fixtures/actions.json index 8f8238b..dae993f 100644 --- a/playerbots/fixtures/actions.json +++ b/playerbots/fixtures/actions.json @@ -7,7 +7,9 @@ "revision": "playerbots-seeded-actions-v1", "provenance": "core seeded templates and reserved fixture rows", "heal_source_spell": 2050, + "heal_target": "named ally, with explicit self still admitted", "creature_source_entry": 51000, + "companion_unrelated_creature_entry": 5090302, "item_source": "first starter item held by the seeded role-1 bot" }, "imported_content": null, @@ -16,7 +18,8 @@ "provenance": "synthetic walk and obstruction arrays", "client_geometry": null, "blocked": "3 by 3 navigation cells, walk bytes 0, obs bytes 20, base_z 50", - "partial": "9 cells along y, one nonwalkable x sub-cell at 1210, other walk bits 1, obs bytes 255" + "partial": "9 cells along y, one nonwalkable x sub-cell at 1210, other walk bits 1, obs bytes 255", + "companion_wall": "synthetic three-cell wall at x 1205, matching nonwalkable and 4-yard obstruction columns; no extracted client geometry" }, "spacetimedb": "2.7.1", "rust": "1.93.0", diff --git a/playerbots/src/companion.rs b/playerbots/src/companion.rs new file mode 100644 index 0000000..658ec2c --- /dev/null +++ b/playerbots/src/companion.rs @@ -0,0 +1,202 @@ +//! Human-led party facts and the Priest's follow and heal Strategies. + +use super::decision::{ + Action, ActionNode, CastAction, MoveTarget, Readiness, Reason, Strategy, Trigger, +}; +use super::{ + pkg_playerbots_bot, pkg_playerbots_personality, PlayerbotsBot, PlayerbotsRotation, ROLE_HEALER, +}; +use spacetimedb::ReducerContext; + +pub(super) struct Party { + pub leader_guid: u64, + pub leader: Option, + pub members: Vec, +} + +impl Party { + pub fn destination(&self) -> Option { + self.leader + .as_ref() + .map(|leader| super::runner::Destination { + map_id: leader.map_id, + instance_id: leader.instance_id, + x: leader.x, + y: leader.y, + z: leader.z, + geometry_revision: None, + }) + } +} + +/// A party is companion-controlled only when its durable leader is not another bot. +pub(super) fn human_led_party(ctx: &ReducerContext, character_guid: u64) -> Option { + let facts = crate::group::party_facts(ctx, character_guid)?; + if ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(facts.leader_guid) + .next() + .is_some() + { + return None; + } + let leader = facts + .members + .iter() + .find(|member| member.character_guid == facts.leader_guid) + .and_then(|member| member.unit.clone()); + Some(Party { + leader_guid: facts.leader_guid, + leader, + members: facts.members, + }) +} + +fn distance_sq(me: &crate::WorldEntity, unit: &crate::group::PartyUnitFacts) -> f32 { + (me.x - unit.x).powi(2) + (me.y - unit.y).powi(2) + (me.z - unit.z).powi(2) +} + +fn node(action: Action, reason: Reason, priority: i32, objective: u64) -> ActionNode { + let mut node = ActionNode::ready(action, reason, priority); + node.candidate.id.objective = objective; + node +} + +fn follow(party: &Party, me: &crate::WorldEntity, objective: u64) -> ActionNode { + let Some(leader) = party.leader.as_ref() else { + return node(Action::Hold, Reason::Follow, 100, objective); + }; + if (leader.map_id, leader.instance_id) != (me.map_id, me.instance_id) + || distance_sq(me, leader) <= 3.05 * 3.05 + { + return node(Action::Hold, Reason::Follow, 100, objective); + } + node( + Action::Move(MoveTarget::Entity(party.leader_guid)), + Reason::Follow, + 100, + objective, + ) +} + +fn wounded_ally(party: &Party, partition: (u32, u64), heal_at_pct: u8) -> Option { + party + .members + .iter() + .filter_map(|member| { + let unit = member.unit.as_ref()?; + ((unit.map_id, unit.instance_id) == partition + && !unit.dead + && unit.max_health > 0 + && u64::from(unit.health) * 100 + <= u64::from(unit.max_health) * u64::from(heal_at_pct)) + .then_some((unit.health, unit.max_health, member.character_guid)) + }) + .min_by(|a, b| { + (u64::from(a.0) * u64::from(b.1), a.2).cmp(&(u64::from(b.0) * u64::from(a.1), b.2)) + }) + .map(|(_, _, guid)| guid) +} + +pub(super) fn strategy( + ctx: &ReducerContext, + bot: &PlayerbotsBot, + me: &crate::WorldEntity, + party: &Party, + heal_spell: Option<&PlayerbotsRotation>, + survival_permits_healing: bool, + objective: u64, +) -> Strategy { + let follow = follow(party, me, objective); + let personality_heal_at = ctx + .db + .pkg_playerbots_personality() + .by_character() + .filter(me.guid) + .next() + .map_or(u8::MAX, |personality| personality.heal_at_pct); + let heal_at_pct = heal_spell.map_or(0, |spell| spell.threshold_pct.min(personality_heal_at)); + let heal = if bot.role == ROLE_HEALER && survival_permits_healing { + heal_spell.and_then(|spell| { + let target = wounded_ally(party, (me.map_id, me.instance_id), heal_at_pct)?; + let cast = CastAction { + target, + spell: spell.spell_id, + }; + let mut heal = node(Action::Cast(cast), Reason::Heal, 800, objective); + match crate::actor::cast_readiness(ctx, me.guid, spell.spell_id, target) { + Ok(()) => {} + Err(refusal) + if matches!( + refusal.kind, + crate::spell::CastRefusalKind::OutOfRange + | crate::spell::CastRefusalKind::NoLineOfSight + ) => + { + let mut position = node( + Action::Move(MoveTarget::CastingPosition(target)), + Reason::CastingPosition, + 800, + objective, + ); + position.alternatives.push(node( + Action::Hold, + Reason::CastingPosition, + 800, + objective, + )); + heal.prerequisites.push(position); + } + Err(_) => heal.readiness = Readiness::Refused, + } + heal.alternatives.push(follow.clone()); + Some(heal) + }) + } else { + None + }; + let mut candidates = Vec::with_capacity(2); + if let Some(heal) = heal { + candidates.push(heal); + } + candidates.push(follow); + Strategy { + trigger: Trigger::Always, + candidates, + defaults: vec![], + priority_adjustment: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(guid: u64, health: u32, max_health: u32) -> crate::group::PartyMemberFacts { + crate::group::PartyMemberFacts { + character_guid: guid, + unit: Some(crate::group::PartyUnitFacts { + map_id: 0, + instance_id: 0, + x: 0.0, + y: 0.0, + z: 0.0, + health, + max_health, + dead: false, + }), + } + } + + #[test] + fn the_most_injured_ally_wins_with_guid_as_the_stable_tie_break() { + let party = Party { + leader_guid: 10, + leader: None, + members: vec![member(12, 20, 100), member(11, 10, 50), member(13, 30, 100)], + }; + assert_eq!(wounded_ally(&party, (0, 0), 50), Some(11)); + } +} diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index 6d8a85e..95eb7cc 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -8,6 +8,7 @@ pub enum Action { Move(MoveTarget), Cast(CastAction), Attack(u64), + Resurrect, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -20,6 +21,7 @@ pub struct CastAction { pub enum MoveTarget { Home, Entity(u64), + CastingPosition(u64), } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -30,6 +32,10 @@ pub enum Reason { Defense, ReturnHome, Idle, + Follow, + Heal, + CastingPosition, + Resurrection, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 0090d0b..48ffa1b 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1,8 +1,8 @@ //! Deterministic staging for private, per-test durable databases. //! Staging replaces shared rotation configuration and is not safe in a shared World Shard. -use super::pkg_playerbots_personality; use super::{pkg_playerbots_bot, PlayerbotsBot}; +use super::{pkg_playerbots_personality, pkg_playerbots_rotation}; use crate::nav::game_nav_chunk; use crate::{ game_creature_spawn, game_creature_template, game_quest_objective, game_quest_template, @@ -11,6 +11,7 @@ use crate::{game_creature_spline, game_spell, game_spell_effect, game_world_enti use spacetimedb::{reducer, ReducerContext, Table}; const HEAL: u32 = 5_090_100; +const COMPANION_GROUP: u64 = 5_090_300; #[reducer] pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { @@ -55,6 +56,7 @@ pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { { effect.spell_id = HEAL; effect.id = ((HEAL as u64) << 2) | effect.effect_index as u64; + effect.target = crate::spell::T_TARGET_ALLY; ctx.db.game_spell_effect().id().delete(effect.id); ctx.db.game_spell_effect().insert(effect); } @@ -131,6 +133,322 @@ pub fn playerbots_fixture_position(ctx: &ReducerContext, guid: u64, x: f32) -> R Ok(()) } +fn companion_unit( + ctx: &ReducerContext, + guid: u64, + x: f32, + y: f32, + health_pct: u32, +) -> Result<(), String> { + let mut entity = crate::helpers::live_entity(ctx, guid)?; + entity.x = x; + entity.y = y; + entity.z = 50.0; + entity.health = (entity.max_health.saturating_mul(health_pct) / 100).max(1); + let (gx, gy) = lyracore_shared::spatial::grid_cell(entity.x, entity.y); + entity.grid_x = gx; + entity.grid_y = gy; + entity.cell = lyracore_shared::spatial::grid_cell_id(gx, gy); + ctx.db.game_world_entity().guid().update(entity); + Ok(()) +} + +fn companion_creature( + ctx: &ReducerContext, + entry: u32, + x: f32, + y: f32, + z: f32, + faction_template: Option, +) -> Result { + let guid = (0xF130u64 << 48) | ((u64::from(entry)) << 24) | 1; + let mut template = ctx + .db + .game_creature_template() + .entry() + .find(51000) + .ok_or("seed creature missing")?; + template.entry = entry; + if let Some(faction_template) = faction_template { + template.faction_template = faction_template; + } + template.aggro_range = 0; + ctx.db.game_creature_template().entry().delete(entry); + let template = ctx.db.game_creature_template().insert(template); + let spawn = crate::CreatureSpawn { + guid, + entry, + map_id: 0, + x, + y, + z, + orientation: 0.0, + respawn_at: ctx.timestamp, + despawn_at: ctx.timestamp, + movement_type: 0, + respawn_secs: 60, + life_seq: 1, + }; + ctx.db.game_creature_spawn().guid().delete(guid); + let spawn = ctx.db.game_creature_spawn().insert(spawn); + crate::creatures::despawn_creature_entity(ctx, guid); + crate::creatures::insert_creature_entity( + ctx, + crate::creatures::build_creature_entity(&spawn, &template, 0, 0), + ); + Ok(guid) +} + +/// Stage one human-led party from otherwise durable spawned Characters. +#[reducer] +pub fn playerbots_fixture_companion_stage( + ctx: &ReducerContext, + companion_guid: u64, + leader_guid: u64, + ally_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut companion = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(companion_guid) + .next() + .ok_or("companion bot missing")?; + let leader_bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(leader_guid) + .next() + .ok_or("leader bot missing")?; + ctx.db.pkg_playerbots_bot().id().delete(leader_bot.id); + companion.next_think_micros = i64::MAX; + let (class, role) = (companion.class, companion.role); + ctx.db.pkg_playerbots_bot().id().update(companion); + companion_unit(ctx, companion_guid, 1200.0, 1200.0, 100)?; + companion_unit(ctx, leader_guid, 1220.0, 1200.0, 100)?; + companion_unit(ctx, ally_guid, 1222.0, 1200.0, 100)?; + companion_creature(ctx, 5_090_302, 1204.0, 1204.0, 50.0, None)?; + crate::spell::learn_spell(ctx, companion_guid, spacetimedb::Identity::ZERO, HEAL); + crate::spell::learn_spell(ctx, leader_guid, spacetimedb::Identity::ZERO, HEAL); + let rotations = ctx.db.pkg_playerbots_rotation(); + for row in rotations + .by_class_role() + .filter((class, role)) + .collect::>() + { + rotations.id().delete(row.id); + } + rotations.insert(super::PlayerbotsRotation { + id: 0, + class, + role, + priority: 0, + spell_id: HEAL, + condition: super::cond::ALLY_HP_BELOW_PCT, + threshold_pct: 80, + }); + let mut personality = ctx + .db + .pkg_playerbots_personality() + .by_character() + .filter(companion_guid) + .next() + .ok_or("companion personality missing")?; + personality.flee_at_pct = 0; + personality.heal_at_pct = 80; + ctx.db.pkg_playerbots_personality().id().update(personality); + crate::group::sync_group_mirror( + ctx, + COMPANION_GROUP, + leader_guid, + 0, + 2, + 0, + vec![leader_guid, companion_guid, ally_guid], + crate::SessionActor { + guid: leader_guid, + ownership: None, + }, + )?; + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_companion_move( + ctx: &ReducerContext, + guid: u64, + x: f32, + y: f32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + companion_unit(ctx, guid, x, y, 100) +} + +#[reducer] +pub fn playerbots_fixture_companion_health( + ctx: &ReducerContext, + guid: u64, + health_pct: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let entity = crate::helpers::live_entity(ctx, guid)?; + companion_unit(ctx, guid, entity.x, entity.y, health_pct) +} + +#[reducer] +pub fn playerbots_fixture_companion_forget_heal( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use crate::game_player_spell; + if let Some(spell) = ctx + .db + .game_player_spell() + .by_character_spell() + .filter((guid, HEAL)) + .next() + { + ctx.db.game_player_spell().id().delete(spell.id); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_companion_learn_heal( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_companion_lesser_heal_target( + ctx: &ReducerContext, + target: u8, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let effects = ctx.db.game_spell_effect(); + let mut effect = effects + .id() + .find(2050u64 << 2) + .ok_or("Lesser Heal effect missing")?; + effect.target = target; + effects.id().update(effect); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_companion_client_cast( + ctx: &ReducerContext, + caster_guid: u64, + target_guid: u64, +) -> Result<(), String> { + crate::gw::gw_cast_at( // package-api: exempt fixture proves client and bot cast Gate parity + ctx, + crate::SessionActor { + guid: caster_guid, + ownership: None, + }, + HEAL, + target_guid, + ) +} + +#[reducer] +pub fn playerbots_fixture_companion_triggered_cast( + ctx: &ReducerContext, + caster_guid: u64, + target_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + crate::spell::cast_triggered(ctx, caster_guid, HEAL, caster.level as u8, target_guid) +} + +#[reducer] +pub fn playerbots_fixture_companion_creature_cast( + ctx: &ReducerContext, + target_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let target = crate::helpers::live_entity(ctx, target_guid)?; + let guid = companion_creature( + ctx, + 5_090_301, + 1200.0, + 1201.0, + target.z, + Some(target.faction_template), + )?; + crate::spell::start_creature_spell( + ctx, + crate::spell::CreatureSpellStart { + caster_guid: guid, + caster_level: target.level as u8, + spell_id: HEAL, + mode: crate::spell::CreatureSpellStartMode::Direct, + target: crate::spell::CreatureSpellTarget::Unit(target_guid), + interrupt_previous: false, + admission: crate::spell::CreatureSpellCasterAdmission::Living, + }, + ) +} + +#[reducer] +pub fn playerbots_fixture_companion_wall( + ctx: &ReducerContext, + caster_guid: u64, + target_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + companion_unit(ctx, caster_guid, 1200.0, 1200.0, 100)?; + companion_unit(ctx, target_guid, 1210.0, 1200.0, 25)?; + let caster = crate::helpers::live_entity(ctx, caster_guid)?; + let wall_x = 1205.0; + let cx = lyracore_shared::terrain::cell_index(wall_x).ok_or("wall off grid")?; + let cy = lyracore_shared::terrain::cell_index(caster.y).ok_or("wall off grid")?; + let walk_sub = lyracore_shared::nav::sub_index(wall_x, cx, lyracore_shared::nav::WALK_DIM) + .ok_or("wall off grid")?; + let obs_sub = lyracore_shared::nav::sub_index(wall_x, cx, lyracore_shared::nav::OBS_DIM) + .ok_or("wall off grid")?; + for cell_y in cy.saturating_sub(1)..=cy.saturating_add(1).min(1023) { + let key = lyracore_shared::terrain::cell_key(caster.map_id, cx, cell_y); + let mut walk = vec![255; lyracore_shared::nav::WALK_BYTES]; + let mut obs = vec![lyracore_shared::nav::OBS_NONE; lyracore_shared::nav::OBS_BYTES]; + for sub_y in 0..lyracore_shared::nav::WALK_DIM { + lyracore_shared::nav::walk_set(&mut walk, walk_sub, sub_y, false); + } + for sub_y in 0..lyracore_shared::nav::OBS_DIM { + lyracore_shared::nav::obs_raise(&mut obs, caster.z, obs_sub, sub_y, caster.z + 4.0); + } + ctx.db.game_nav_chunk().key().delete(key); + ctx.db.game_nav_chunk().insert(crate::nav::NavChunk { + key, + map_id: caster.map_id, + cell_x: cx, + cell_y, + base_z: caster.z, + walk, + obs, + }); + } + if crate::nav::has_los( + ctx, + caster.map_id, + caster.instance_id, + (caster.x, caster.y, caster.z), + (1210.0, 1200.0, caster.z), + ) { + return Err("synthetic wall did not block line of sight".to_string()); + } + runner_due_for(ctx, caster_guid) +} + #[reducer] pub fn playerbots_fixture_blocked_quest(ctx: &ReducerContext, guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -580,7 +898,6 @@ pub fn playerbots_fixture_runner_stage( ctx.db.pkg_playerbots_personality().id().update(personality); if healing { crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); - use super::pkg_playerbots_rotation; let rows = ctx.db.pkg_playerbots_rotation(); for row in rows .by_class_role() @@ -710,6 +1027,12 @@ fn runner_due_for(ctx: &ReducerContext, guid: u64) -> Result<(), String> { Ok(()) } +#[reducer] +pub fn playerbots_fixture_companion_due(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + runner_due_for(ctx, guid) +} + #[reducer] pub fn playerbots_fixture_runner_wide_recovery( ctx: &ReducerContext, @@ -734,7 +1057,6 @@ pub fn playerbots_fixture_runner_wide_recovery( }); } spells.insert(crate::spell::PlayerSpell { id: 0, ..heal }); - use super::pkg_playerbots_rotation; let rotations = ctx.db.pkg_playerbots_rotation(); let bot = ctx .db diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index 3005c27..fb0ce2b 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -19,6 +19,7 @@ use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; mod actions; +mod companion; mod decision; mod goals; mod runner; diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 9bf5770..dc053bc 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -43,6 +43,7 @@ pub struct Destination { #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub enum ObjectiveKind { ReturnHome, + Companion, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] @@ -189,6 +190,9 @@ pub struct PlayerbotsRunner { pub transitions: u32, pub route_expansions: u32, pub route_budget: u32, + /// The stable party leader identity. A moving leader only refreshes the companion destination. + #[default(None::)] + pub companion_leader_guid: Option, } crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_runner(ctx, character_guid) { @@ -360,6 +364,7 @@ impl PlayerbotsRunner { transitions: 0, route_expansions: 0, route_budget: 0, + companion_leader_guid: None, } } @@ -603,37 +608,90 @@ fn distance(me: &crate::WorldEntity, dest: &Destination) -> f32 { ((me.x - dest.x).powi(2) + (me.y - dest.y).powi(2) + (me.z - dest.z).powi(2)).sqrt() } -fn objective(ctx: &ReducerContext, bot: &PlayerbotsBot, state: &mut PlayerbotsRunner, now: i64) { - let destination = Destination { - map_id: bot.home_map, - instance_id: 0, - x: bot.home_x, - y: bot.home_y, - z: bot.home_z, - geometry_revision: geometry_revision(ctx, bot.home_map), +fn objective( + ctx: &ReducerContext, + bot: &PlayerbotsBot, + me: &crate::WorldEntity, + party: Option<&super::companion::Party>, + state: &mut PlayerbotsRunner, + now: i64, +) { + let (kind, leader_guid, mut destination) = if let Some(party) = party { + let destination = party.destination().or_else(|| { + state + .objective + .as_ref() + .filter(|objective| { + objective.kind == ObjectiveKind::Companion + && state.companion_leader_guid == Some(party.leader_guid) + }) + .map(|objective| objective.destination.clone()) + }); + ( + ObjectiveKind::Companion, + Some(party.leader_guid), + destination.unwrap_or(Destination { + map_id: me.map_id, + instance_id: me.instance_id, + x: me.x, + y: me.y, + z: me.z, + geometry_revision: geometry_revision(ctx, me.map_id), + }), + ) + } else { + ( + ObjectiveKind::ReturnHome, + None, + Destination { + map_id: bot.home_map, + instance_id: 0, + x: bot.home_x, + y: bot.home_y, + z: bot.home_z, + geometry_revision: geometry_revision(ctx, bot.home_map), + }, + ) }; + destination.geometry_revision = geometry_revision(ctx, destination.map_id); state.deferred_destinations.retain(|d| d.until_micros > now); - if state - .objective - .as_ref() - .is_none_or(|o| o.destination != destination) - { + let same_identity = state.objective.as_ref().is_some_and(|objective| { + objective.kind == kind + && (kind != ObjectiveKind::Companion || state.companion_leader_guid == leader_guid) + }); + let replace = !same_identity + || (kind == ObjectiveKind::ReturnHome + && state + .objective + .as_ref() + .is_none_or(|objective| objective.destination != destination)); + if replace { state.objective_sequence = state.objective_sequence.saturating_add(1); state.objective = Some(Objective { identity: state.objective_sequence, - kind: ObjectiveKind::ReturnHome, + kind, destination, stage: ObjectiveStage::Travelling, - deadline_micros: now.saturating_add(OBJECTIVE_LIFETIME), + deadline_micros: if kind == ObjectiveKind::Companion { + i64::MAX + } else { + now.saturating_add(OBJECTIVE_LIFETIME) + }, last_verified_progress_micros: None, started_micros: now, catalog_revision: CATALOG_REVISION, }); + state.companion_leader_guid = leader_guid; state.retry_count = 0; state.last_stall_check_micros = now; + } else if kind == ObjectiveKind::Companion { + if let Some(objective) = &mut state.objective { + objective.destination = destination; + } } if let Some(o) = &mut state.objective { - if o.stage == ObjectiveStage::Deferred + if o.kind == ObjectiveKind::ReturnHome + && o.stage == ObjectiveStage::Deferred && !state .deferred_destinations .iter() @@ -644,7 +702,7 @@ fn objective(ctx: &ReducerContext, bot: &PlayerbotsBot, state: &mut PlayerbotsRu state.retry_count = 0; state.last_stall_check_micros = now; } - if o.stage == ObjectiveStage::Deferred { + if o.kind == ObjectiveKind::ReturnHome && o.stage == ObjectiveStage::Deferred { if let Some(deferred) = state .deferred_destinations .iter() @@ -791,26 +849,30 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n state.save(ctx); return; }; + let party = super::companion::human_led_party(ctx, me.guid); let prior_objective = state.objective_sequence; - objective(ctx, bot, &mut state, now); + objective(ctx, bot, &me, party.as_ref(), &mut state, now); if bot.controller == Controller::Cohort { if prior_objective != state.objective_sequence && state.foreground.is_some() { stop(ctx, me.guid, &mut state); } observe(ctx, &me, &mut state, now); } - let Some(home) = state.objective.as_ref().map(|o| o.destination.clone()) else { + let Some(destination) = state.objective.as_ref().map(|o| o.destination.clone()) else { return; }; - let partition_ok = (home.map_id, home.instance_id) == (me.map_id, me.instance_id); - let at_home = partition_ok && distance(&me, &home) <= 2.05; + let partition_ok = (destination.map_id, destination.instance_id) == (me.map_id, me.instance_id); + let stop_distance = if party.is_some() { 3.05 } else { 2.05 }; + let at_destination = partition_ok && distance(&me, &destination) <= stop_distance; if bot.controller == Controller::Cohort { if let Some(o) = &mut state.objective { - if at_home { + if at_destination { o.stage = ObjectiveStage::Completed; } else if o.stage == ObjectiveStage::Completed { o.stage = ObjectiveStage::Travelling; - o.deadline_micros = now.saturating_add(OBJECTIVE_LIFETIME); + if o.kind == ObjectiveKind::ReturnHome { + o.deadline_micros = now.saturating_add(OBJECTIVE_LIFETIME); + } state.last_stall_check_micros = now; } } @@ -833,29 +895,29 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n RecoveryLookup::Spell(row) => Some(row), RecoveryLookup::Pending | RecoveryLookup::Missing => None, }; - let restricted = me.dead || crate::helpers::live_entity(ctx, me.guid).is_err(); + let restricted = crate::helpers::live_entity(ctx, me.guid).is_err(); let facts = decision::Facts { now, restricted, low_health, wounded: u64::from(me.health) * 100 < u64::from(me.max_health) * 50, attacked: threat.is_some(), - away: !at_home, + away: !at_destination, }; let node = |action, reason, priority| { let mut node = ActionNode::ready(action, reason, priority); node.candidate.id.objective = state.objective_sequence; node }; - let mut home_action = node(Action::Move(MoveTarget::Home), Reason::ReturnHome, 100); - home_action.readiness = if !partition_ok { + let mut travel_action = node(Action::Move(MoveTarget::Home), Reason::ReturnHome, 100); + travel_action.readiness = if !partition_ok { Readiness::Refused } else if state.next_eligible_micros > now { Readiness::NotBefore(state.next_eligible_micros) } else { Readiness::Ready }; - home_action + travel_action .alternatives .push(node(Action::Hold, Reason::ReturnHome, 100)); let mut recovery = node( @@ -892,35 +954,28 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n } defense.prerequisites.push(close); } - defense.continuers.push(home_action.clone()); - let mut survival = if partition_ok && !at_home { + if party.is_none() { + defense.continuers.push(travel_action.clone()); + } + let mut survival = if partition_ok && !at_destination { node(Action::Move(MoveTarget::Home), Reason::Survival, 900) } else { node(Action::Hold, Reason::Survival, 900) }; + if at_destination { + survival.readiness = Readiness::Complete; + } if let Some(deferred) = state .deferred_destinations .iter() - .find(|d| d.destination == home) + .find(|d| d.destination == destination) { survival.readiness = Readiness::NotBefore(deferred.until_micros); survival .alternatives .push(node(Action::Hold, Reason::Survival, 900)); } - let strategies: Vec<_> = [ - ( - Trigger::Restricted, - node(Action::Hold, Reason::Restricted, 1000), - ), - (Trigger::LowHealth, survival), - (Trigger::Wounded, recovery), - (Trigger::Attacked, defense), - (Trigger::Away, home_action), - (Trigger::Always, node(Action::Hold, Reason::Idle, 0)), - ] - .into_iter() - .map(|(trigger, mut n)| { + let strategy = |trigger, mut n: ActionNode| { if state.retry_candidate == Some(n.candidate.id) && state.next_eligible_micros > now { n.readiness = Readiness::NotBefore(state.next_eligible_micros); } @@ -930,8 +985,40 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n defaults: vec![], priority_adjustment: 0, } - }) - .collect(); + }; + let mut strategies = vec![strategy( + Trigger::Restricted, + node(Action::Hold, Reason::Restricted, 1000), + )]; + if me.dead { + strategies.push(strategy( + Trigger::Always, + node(Action::Resurrect, Reason::Resurrection, 1000), + )); + } else { + strategies.push(strategy(Trigger::LowHealth, survival)); + if party.is_none() { + strategies.push(strategy(Trigger::Wounded, recovery)); + } + strategies.push(strategy(Trigger::Attacked, defense)); + if let Some(party) = party.as_ref() { + strategies.push(super::companion::strategy( + ctx, + bot, + &me, + party, + spell, + !low_health || at_destination, + state.objective_sequence, + )); + } else { + strategies.push(strategy(Trigger::Away, travel_action)); + } + } + strategies.push(strategy( + Trigger::Always, + node(Action::Hold, Reason::Idle, 0), + )); let decision = decision::choose(&facts, &strategies, decision::LIMITS); state.candidate_order = decision.order; state.transitions = decision.transitions as u32; @@ -947,7 +1034,11 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n if let Some(deadline) = state .objective .as_ref() - .filter(|o| o.stage == ObjectiveStage::Travelling && now >= o.deadline_micros) + .filter(|o| { + o.kind == ObjectiveKind::ReturnHome + && o.stage == ObjectiveStage::Travelling + && now >= o.deadline_micros + }) .map(|o| o.deadline_micros) { if let Some(foreground) = state.foreground.clone() { @@ -1004,7 +1095,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n } state.chosen = chosen; if let Some(candidate) = chosen { - execute(ctx, &me, &home, candidate, &mut state, now); + execute(ctx, &me, &destination, candidate, &mut state, now); } else if let Some(reason) = decision.refusals.first() { state.failure(Failure::Decision(*reason), now); } @@ -1015,7 +1106,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n fn execute( ctx: &ReducerContext, me: &crate::WorldEntity, - home: &Destination, + objective_destination: &Destination, candidate: Candidate, state: &mut PlayerbotsRunner, now: i64, @@ -1027,7 +1118,10 @@ fn execute( stop(ctx, me.guid, state); } if candidate.id.reason == Reason::ReturnHome - && (home.map_id, home.instance_id) != (me.map_id, me.instance_id) + && ( + objective_destination.map_id, + objective_destination.instance_id, + ) != (me.map_id, me.instance_id) && state .objective .as_ref() @@ -1049,27 +1143,32 @@ fn execute( } Action::Move(target) => { let destination = match target { - MoveTarget::Home => Some(home.clone()), - MoveTarget::Entity(guid) => { - ctx.db - .game_world_entity() - .guid() - .find(guid) - .map(|e| Destination { - map_id: e.map_id, - instance_id: e.instance_id, - x: e.x, - y: e.y, - z: e.z, - geometry_revision: geometry_revision(ctx, e.map_id), - }) - } + MoveTarget::Home => Some(objective_destination.clone()), + MoveTarget::Entity(guid) | MoveTarget::CastingPosition(guid) => ctx + .db + .game_world_entity() + .guid() + .find(guid) + .map(|e| Destination { + map_id: e.map_id, + instance_id: e.instance_id, + x: e.x, + y: e.y, + z: e.z, + geometry_revision: geometry_revision(ctx, e.map_id), + }), }; let Some(dest) = destination.filter(|d| (d.map_id, d.instance_id) == (me.map_id, me.instance_id)) else { state.failure(Failure::DestinationUnavailable, now); - defer(state, now); + if state + .objective + .as_ref() + .is_some_and(|objective| objective.kind == ObjectiveKind::ReturnHome) + { + defer(state, now); + } return; }; if candidate.id.reason == Reason::Survival { @@ -1080,7 +1179,13 @@ fn execute( state.last_stall_check_micros = now; if state.retry_count >= 3 { stop(ctx, me.guid, state); - defer(state, now); + if state + .objective + .as_ref() + .is_some_and(|objective| objective.kind == ObjectiveKind::ReturnHome) + { + defer(state, now); + } return; } } @@ -1172,6 +1277,21 @@ fn execute( Err(reason) => state.failure(Failure::ActionRefused(reason.kind), now), } } + Action::Resurrect => { + stop(ctx, me.guid, state); + let result = if me.player_flags & lyracore_shared::constants::player_flags::GHOST != 0 { + crate::actor::spirit_res(ctx, me.guid) + } else { + crate::actor::repop(ctx, me.guid) + }; + match result { + Ok(()) => state.last_outcome = RunnerOutcome::Accepted, + Err(_) => state.failure( + Failure::ActionRefused(crate::actor::ActionRefusalKind::Other), + now, + ), + } + } } } From df4f7cf9ffcb909c99c82b132d23c062924798af Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:09:53 +0000 Subject: [PATCH 02/16] fix(playerbots): retain injured companion targets --- playerbots/README.md | 20 +++--- playerbots/fixtures/actions.json | 3 +- playerbots/src/companion.rs | 103 ++++++++++++++++++++++++------- playerbots/src/decision.rs | 1 + playerbots/src/fixture.rs | 93 +++++++++++++++++++++++++++- playerbots/src/runner.rs | 73 ++++++++++++++++++++-- 6 files changed, 254 insertions(+), 39 deletions(-) diff --git a/playerbots/README.md b/playerbots/README.md index 8ce295b..2dc250a 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -425,10 +425,12 @@ The Cohort behavior follows the durable leader of a human-led party. It refreshe position without replacing the companion objective, so a moving leader cannot restart a retained cast. An injured same-partition party member outranks follow when the Priest knows a supported healing rotation spell. Range or line of sight becomes a movement prerequisite that retains the -member identity. A blocked route remains visible as a CastingPosition wait. A completed, cancelled, -or refused cast releases the foreground action so the next pass can heal again or resume follow. +member identity across movement legs even if another member becomes more injured. A blocked route +remains visible as a CastingPosition wait. A completed, cancelled, or refused cast releases the +foreground action so the next pass can heal again or resume follow. An ungrouped Cohort returns to its roster home point. A bot-led party does not activate companion -control. The existing Legacy policy remains available under its explicit selector. +control. A membership whose parent Group is unavailable holds the existing objective and records the +typed failure. The existing Legacy policy remains available under its explicit selector. A dead companion retains its role and objective while it releases and uses the spirit healer. Once alive, it regroups with the same leader. Survival movement has priority over healing while the bot @@ -437,7 +439,8 @@ can run. If recovery is missing, the runner leaves lower-priority maintenance re hiding it behind a Survival Hold. Recovery scans at most 24 indexed healing rotation rows and revalidates at most two retained -rows per pass. The separate +rows per pass. Learned channels are excluded during that scan, before priority chooses the retained +rotation. The separate `pkg_playerbots_recovery_scan` explanation records its indexed cursor, scanned row count, and Pending or Complete stage. A first incomplete scan selects Recovery Hold. A completed missing-spell result keeps lower-priority actions eligible while later scans continue. Completed @@ -479,12 +482,13 @@ The roster selector travels with the Character; runner observations and foregrou The Character delete operation removes the runner and recovery scan rows. Production publication still requires the schema review described in LyraCore's `docs/danger-zones.md`. -The companion migration appends a nullable leader identity to each runner row. Its populated upgrade -case starts from merged PB-002 core `e6a755db0a150bbf73ad97b972fe829f20f6816c` and collection +The companion migration appends nullable leader and injured-member identities to each runner row. +Its populated upgrade case starts from merged PB-002 core +`e6a755db0a150bbf73ad97b972fe829f20f6816c` and collection `155c9e401afb06d5731acedf8fc35a81dbe4aaa6`. It publishes a retained return-home objective and cast foreground, upgrades the same private Standalone, and checks that both values survive while the new -leader field receives its null default. Set `PLAYERBOTS_COMPANION_PRECEDING_WASM` to that immutable -Wasm when running the companion target outside CI. +identity fields receive their null defaults. Set `PLAYERBOTS_COMPANION_PRECEDING_WASM` to that +immutable Wasm when running the companion target outside CI. Fresh Shards seed Lesser Heal 2050 as a heal for a named ally. Existing Shards keep their stored spell definitions when the Module is published, so the normal `debug_repair_after_publish` step diff --git a/playerbots/fixtures/actions.json b/playerbots/fixtures/actions.json index dae993f..2caea14 100644 --- a/playerbots/fixtures/actions.json +++ b/playerbots/fixtures/actions.json @@ -34,7 +34,8 @@ "spell": 5090100, "source_spell": 2050, "cast_time_ms": 5000, - "cost": 0 + "cost": 0, + "unsupported_channel_spell": 5090104 }, "poor_range_target_x": 1400, "quest": { diff --git a/playerbots/src/companion.rs b/playerbots/src/companion.rs index 658ec2c..6ebe34a 100644 --- a/playerbots/src/companion.rs +++ b/playerbots/src/companion.rs @@ -30,8 +30,13 @@ impl Party { } /// A party is companion-controlled only when its durable leader is not another bot. -pub(super) fn human_led_party(ctx: &ReducerContext, character_guid: u64) -> Option { - let facts = crate::group::party_facts(ctx, character_guid)?; +pub(super) fn human_led_party( + ctx: &ReducerContext, + character_guid: u64, +) -> Result, crate::group::PartyFactsUnavailable> { + let Some(facts) = crate::group::party_facts(ctx, character_guid)? else { + return Ok(None); + }; if ctx .db .pkg_playerbots_bot() @@ -40,18 +45,18 @@ pub(super) fn human_led_party(ctx: &ReducerContext, character_guid: u64) -> Opti .next() .is_some() { - return None; + return Ok(None); } let leader = facts .members .iter() .find(|member| member.character_guid == facts.leader_guid) .and_then(|member| member.unit.clone()); - Some(Party { + Ok(Some(Party { leader_guid: facts.leader_guid, leader, members: facts.members, - }) + })) } fn distance_sq(me: &crate::WorldEntity, unit: &crate::group::PartyUnitFacts) -> f32 { @@ -81,25 +86,48 @@ fn follow(party: &Party, me: &crate::WorldEntity, objective: u64) -> ActionNode ) } -fn wounded_ally(party: &Party, partition: (u32, u64), heal_at_pct: u8) -> Option { +fn wounded_member( + member: &crate::group::PartyMemberFacts, + partition: (u32, u64), + heal_at_pct: u8, +) -> Option<(u32, u32, u64)> { + let unit = member.unit.as_ref()?; + ((unit.map_id, unit.instance_id) == partition + && !unit.dead + && unit.max_health > 0 + && u64::from(unit.health) * 100 <= u64::from(unit.max_health) * u64::from(heal_at_pct)) + .then_some((unit.health, unit.max_health, member.character_guid)) +} + +fn wounded_ally( + party: &Party, + partition: (u32, u64), + heal_at_pct: u8, + retained: Option, +) -> Option { + if let Some(retained) = retained.filter(|guid| { + party.members.iter().any(|member| { + member.character_guid == *guid + && wounded_member(member, partition, heal_at_pct).is_some() + }) + }) { + return Some(retained); + } party .members .iter() - .filter_map(|member| { - let unit = member.unit.as_ref()?; - ((unit.map_id, unit.instance_id) == partition - && !unit.dead - && unit.max_health > 0 - && u64::from(unit.health) * 100 - <= u64::from(unit.max_health) * u64::from(heal_at_pct)) - .then_some((unit.health, unit.max_health, member.character_guid)) - }) + .filter_map(|member| wounded_member(member, partition, heal_at_pct)) .min_by(|a, b| { (u64::from(a.0) * u64::from(b.1), a.2).cmp(&(u64::from(b.0) * u64::from(a.1), b.2)) }) .map(|(_, _, guid)| guid) } +pub(super) struct CompanionSelection { + pub strategy: Strategy, + pub heal_target: Option, +} + pub(super) fn strategy( ctx: &ReducerContext, bot: &PlayerbotsBot, @@ -108,7 +136,8 @@ pub(super) fn strategy( heal_spell: Option<&PlayerbotsRotation>, survival_permits_healing: bool, objective: u64, -) -> Strategy { + retained_heal_target: Option, +) -> CompanionSelection { let follow = follow(party, me, objective); let personality_heal_at = ctx .db @@ -118,9 +147,31 @@ pub(super) fn strategy( .next() .map_or(u8::MAX, |personality| personality.heal_at_pct); let heal_at_pct = heal_spell.map_or(0, |spell| spell.threshold_pct.min(personality_heal_at)); - let heal = if bot.role == ROLE_HEALER && survival_permits_healing { + let heal_target = if bot.role == ROLE_HEALER { + heal_spell.and_then(|_| { + retained_heal_target + .and_then(|target| { + wounded_ally( + party, + (me.map_id, me.instance_id), + heal_at_pct, + Some(target), + ) + }) + .or_else(|| { + if survival_permits_healing { + wounded_ally(party, (me.map_id, me.instance_id), heal_at_pct, None) + } else { + None + } + }) + }) + } else { + None + }; + let heal = if survival_permits_healing { heal_spell.and_then(|spell| { - let target = wounded_ally(party, (me.map_id, me.instance_id), heal_at_pct)?; + let target = heal_target?; let cast = CastAction { target, spell: spell.spell_id, @@ -162,11 +213,14 @@ pub(super) fn strategy( candidates.push(heal); } candidates.push(follow); - Strategy { - trigger: Trigger::Always, - candidates, - defaults: vec![], - priority_adjustment: 0, + CompanionSelection { + strategy: Strategy { + trigger: Trigger::Always, + candidates, + defaults: vec![], + priority_adjustment: 0, + }, + heal_target, } } @@ -197,6 +251,7 @@ mod tests { leader: None, members: vec![member(12, 20, 100), member(11, 10, 50), member(13, 30, 100)], }; - assert_eq!(wounded_ally(&party, (0, 0), 50), Some(11)); + assert_eq!(wounded_ally(&party, (0, 0), 50, None), Some(11)); + assert_eq!(wounded_ally(&party, (0, 0), 50, Some(12)), Some(12)); } } diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index 95eb7cc..59f316f 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -36,6 +36,7 @@ pub enum Reason { Heal, CastingPosition, Resurrection, + PartyUnavailable, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 48ffa1b..4ac5de4 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -5,12 +5,14 @@ use super::{pkg_playerbots_bot, PlayerbotsBot}; use super::{pkg_playerbots_personality, pkg_playerbots_rotation}; use crate::nav::game_nav_chunk; use crate::{ - game_creature_spawn, game_creature_template, game_quest_objective, game_quest_template, + game_creature_spawn, game_creature_template, game_group, game_quest_objective, + game_quest_template, }; use crate::{game_creature_spline, game_spell, game_spell_effect, game_world_entity}; use spacetimedb::{reducer, ReducerContext, Table}; const HEAL: u32 = 5_090_100; +const CHANNEL_HEAL: u32 = 5_090_104; const COMPANION_GROUP: u64 = 5_090_300; #[reducer] @@ -297,6 +299,13 @@ pub fn playerbots_fixture_companion_health( companion_unit(ctx, guid, entity.x, entity.y, health_pct) } +#[reducer] +pub fn playerbots_fixture_companion_remove_group(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + ctx.db.game_group().group_id().delete(COMPANION_GROUP); + Ok(()) +} + #[reducer] pub fn playerbots_fixture_companion_forget_heal( ctx: &ReducerContext, @@ -342,6 +351,88 @@ pub fn playerbots_fixture_companion_lesser_heal_target( Ok(()) } +#[reducer] +pub fn playerbots_fixture_companion_extra_lesser_heal_effect( + ctx: &ReducerContext, + present: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let effects = ctx.db.game_spell_effect(); + let id = (2050u64 << 2) | 1; + effects.id().delete(id); + if present { + let mut effect = effects + .id() + .find(2050u64 << 2) + .ok_or("Lesser Heal effect missing")?; + effect.id = id; + effect.effect_index = 1; + effects.insert(effect); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_companion_mixed_heals( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut channel = ctx + .db + .game_spell() + .spell_id() + .find(HEAL) + .ok_or("fixture heal missing")?; + channel.spell_id = CHANNEL_HEAL; + channel.name = "Unsupported channel heal".to_string(); + channel.cast_flags = crate::spell::SPELL_ATTR_CHANNELED; + ctx.db.game_spell().spell_id().delete(CHANNEL_HEAL); + ctx.db.game_spell().insert(channel); + let effects = ctx.db.game_spell_effect(); + for row in effects + .by_spell() + .filter(&CHANNEL_HEAL) + .collect::>() + { + effects.id().delete(row.id); + } + for mut effect in effects.by_spell().filter(&HEAL).collect::>() { + effect.spell_id = CHANNEL_HEAL; + effect.id = (u64::from(CHANNEL_HEAL) << 2) | u64::from(effect.effect_index); + effects.insert(effect); + } + crate::spell::learn_spell( + ctx, + character_guid, + spacetimedb::Identity::ZERO, + CHANNEL_HEAL, + ); + let rotations = ctx.db.pkg_playerbots_rotation(); + let mut supported = rotations + .iter() + .find(|row| row.spell_id == HEAL) + .ok_or("supported heal rotation missing")?; + supported.priority = 1; + let (class, role, condition, threshold_pct) = ( + supported.class, + supported.role, + supported.condition, + supported.threshold_pct, + ); + rotations.id().update(supported); + rotations.insert(super::PlayerbotsRotation { + id: 0, + class, + role, + priority: 0, + spell_id: CHANNEL_HEAL, + condition, + threshold_pct, + }); + Ok(()) +} + #[reducer] pub fn playerbots_fixture_companion_client_cast( ctx: &ReducerContext, diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index dc053bc..387c89f 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -9,7 +9,7 @@ use super::{ pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, PlayerbotsRotation, }; -use crate::{game_character_quest, game_creature_spline, game_world_entity}; +use crate::{game_character_quest, game_creature_spline, game_spell, game_world_entity}; use spacetimedb::{reducer, table, ReducerContext, Table}; pub const BATCH_LIMIT: usize = 16; @@ -74,6 +74,7 @@ pub enum Failure { ActionRefused(crate::actor::ActionRefusalKind), CastRefused(crate::spell::CastRefusalKind), Decision(DecisionRefusal), + PartyFactsUnavailable, } #[derive(spacetimedb::SpacetimeType, Clone, Debug)] @@ -193,6 +194,9 @@ pub struct PlayerbotsRunner { /// The stable party leader identity. A moving leader only refreshes the companion destination. #[default(None::)] pub companion_leader_guid: Option, + /// The injured party member retained from CastingPosition movement through cast completion. + #[default(None::)] + pub companion_heal_target_guid: Option, } crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_runner(ctx, character_guid) { @@ -272,6 +276,12 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { (row.class, row.role, row.condition) == (bot.class, bot.role, super::cond::ALLY_HP_BELOW_PCT) && crate::spell::knows_spell(ctx, bot.character_guid, row.spell_id) + && ctx + .db + .game_spell() + .spell_id() + .find(row.spell_id) + .is_some_and(|spell| spell.cast_flags & crate::spell::SPELL_ATTR_CHANNELED == 0) }; if scan.stage == ScanStage::Complete { scan.after_rotation_id = 0; @@ -365,6 +375,7 @@ impl PlayerbotsRunner { route_expansions: 0, route_budget: 0, companion_leader_guid: None, + companion_heal_target_guid: None, } } @@ -549,6 +560,13 @@ pub fn playerbots_select_controller( fn stop(ctx: &ReducerContext, guid: u64, state: &mut PlayerbotsRunner) { if let Some(foreground) = state.foreground.take() { + if matches!( + &foreground.running, + Running::Cast(handle) + if state.companion_heal_target_guid == Some(handle.target_guid) + ) { + state.companion_heal_target_guid = None; + } bounded_push( &mut state.history, Transition { @@ -682,6 +700,7 @@ fn objective( catalog_revision: CATALOG_REVISION, }); state.companion_leader_guid = leader_guid; + state.companion_heal_target_guid = None; state.retry_count = 0; state.last_stall_check_micros = now; } else if kind == ObjectiveKind::Companion { @@ -849,7 +868,30 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n state.save(ctx); return; }; - let party = super::companion::human_led_party(ctx, me.guid); + let party = match super::companion::human_led_party(ctx, me.guid) { + Ok(party) => party, + Err(unavailable) => { + spacetimedb::log::error!( + "party facts unavailable: member {} points to missing group {}", + me.guid, + unavailable.group_id + ); + if bot.controller == Controller::Cohort { + stop(ctx, me.guid, &mut state); + } + state.chosen = Some(Candidate { + id: decision::CandidateId { + action: Action::Hold, + reason: Reason::PartyUnavailable, + objective: state.objective_sequence, + }, + priority: 1000, + }); + state.failure(Failure::PartyFactsUnavailable, now); + state.save(ctx); + return; + } + }; let prior_objective = state.objective_sequence; objective(ctx, bot, &me, party.as_ref(), &mut state, now); if bot.controller == Controller::Cohort { @@ -990,6 +1032,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n Trigger::Restricted, node(Action::Hold, Reason::Restricted, 1000), )]; + let mut companion_heal_target = state.companion_heal_target_guid; if me.dead { strategies.push(strategy( Trigger::Always, @@ -1002,7 +1045,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n } strategies.push(strategy(Trigger::Attacked, defense)); if let Some(party) = party.as_ref() { - strategies.push(super::companion::strategy( + let selection = super::companion::strategy( ctx, bot, &me, @@ -1010,8 +1053,12 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n spell, !low_health || at_destination, state.objective_sequence, - )); + state.companion_heal_target_guid, + ); + companion_heal_target = selection.heal_target; + strategies.push(selection.strategy); } else { + companion_heal_target = None; strategies.push(strategy(Trigger::Away, travel_action)); } } @@ -1019,6 +1066,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n Trigger::Always, node(Action::Hold, Reason::Idle, 0), )); + state.companion_heal_target_guid = companion_heal_target; let decision = decision::choose(&facts, &strategies, decision::LIMITS); state.candidate_order = decision.order; state.transitions = decision.transitions as u32; @@ -1065,7 +1113,13 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n } if let Some(fg) = &state.foreground { let incompatible = chosen.is_some_and(|c| c.id != fg.candidate.id); - let preempts = chosen.is_some_and(|c| c.priority > fg.candidate.priority); + let casting_target_invalid = matches!( + fg.candidate.id.action, + Action::Move(MoveTarget::CastingPosition(target)) + if state.companion_heal_target_guid != Some(target) + ); + let preempts = + casting_target_invalid || chosen.is_some_and(|c| c.priority > fg.candidate.priority); if incompatible && preempts { stop(ctx, me.guid, &mut state); state.last_outcome = RunnerOutcome::Cancelled; @@ -1251,10 +1305,16 @@ fn execute( }); state.last_outcome = RunnerOutcome::CastFinished(crate::spell::CastFinish::Resolved); + if state.companion_heal_target_guid == Some(target) { + state.companion_heal_target_guid = None; + } state.retry_candidate = None; } Err(reason) => { state.failure(Failure::CastRefused(reason.kind), now); + if state.companion_heal_target_guid == Some(target) { + state.companion_heal_target_guid = None; + } state.retry_candidate = Some(candidate.id); state.next_eligible_micros = now.saturating_add(if state.retry_count >= 3 { DEFER_INTERVAL @@ -1309,6 +1369,9 @@ crate::game_hook!(on_cast_finished, fn playerbots_runner_cast_finished(ctx, payl state.retry_candidate = None; } state.foreground = None; + if state.companion_heal_target_guid == Some(payload.target_guid) { + state.companion_heal_target_guid = None; + } state.last_stall_check_micros = now; state.last_outcome = RunnerOutcome::CastFinished(payload.outcome.clone()); state.save(ctx); From a073a3b24369c6910ca805c27af6daf9456acbf3 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:31:24 +0000 Subject: [PATCH 03/16] ci(playerbots): test companion against paired core --- .github/workflows/core-tip.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 1460cf8..924ad2f 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -8,6 +8,9 @@ on: permissions: contents: read +env: + LYRACORE_REF: bfb57b6cd5d04df702adb7cdba57ede439aca024 + concurrency: group: core-tip-${{ github.ref }} cancel-in-progress: true @@ -40,11 +43,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out paired LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false @@ -66,11 +69,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out paired LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false @@ -130,11 +133,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out paired LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target From 1d0d579d54cf6e04ccc60034e7af2a22a236604d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:42:30 +0000 Subject: [PATCH 04/16] ci(playerbots): test staged cast correction --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 924ad2f..2572925 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: bfb57b6cd5d04df702adb7cdba57ede439aca024 + LYRACORE_REF: d3f5398372e91d990fb9a16671c18829e9441c21 concurrency: group: core-tip-${{ github.ref }} From 4df30ffa0ec801bbced62de6be5805310ec8fa75 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:53:21 +0000 Subject: [PATCH 05/16] ci(playerbots): verify predecessor build inputs --- .github/workflows/core-tip.yml | 76 ++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 2572925..f0964bc 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: d3f5398372e91d990fb9a16671c18829e9441c21 + LYRACORE_REF: 5433d21e3eaeaefccbc7f9921bddd2bc3b3c85a8 concurrency: group: core-tip-${{ github.ref }} @@ -209,16 +209,84 @@ jobs: cargo build --manifest-path "$GITHUB_WORKSPACE/preceding-pb002-core/Cargo.toml" \ --locked --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers cp "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/lyracore_module.wasm" "$RUNNER_TEMP/playerbots-pb002-preceding.wasm" + python3 - "$RUNNER_TEMP/playerbots-pb002-preceding.wasm" \ + "$GITHUB_WORKSPACE/preceding-pb002-core" \ + "$GITHUB_WORKSPACE/preceding-pb002-collection" \ + "$RUNNER_TEMP/playerbots-pb002-preceding-inputs.json" <<'PY' + import datetime + import hashlib + import json + import pathlib + import subprocess + import sys + + wasm_path, core_path, collection_path, manifest_path = map(pathlib.Path, sys.argv[1:]) + + def git(path, *args): + return subprocess.run( + ["git", *args], cwd=path, check=True, text=True, capture_output=True + ).stdout.strip() + + if git(core_path, "status", "--porcelain"): + raise SystemExit("the preceding PB-002 Module checkout is dirty") + if git(collection_path, "status", "--porcelain"): + raise SystemExit("the preceding PB-002 Package checkout is dirty") + + wasm = wasm_path.read_bytes() + rust_detail = subprocess.run( + ["rustc", "--version"], check=True, text=True, capture_output=True + ).stdout.strip() + spacetime_detail = subprocess.run( + ["spacetime", "--version"], check=True, text=True, capture_output=True + ).stdout.strip() + installed_packages = sorted( + path.name + for root in (core_path / "packages", collection_path) + for path in root.iterdir() + if path.is_dir() and not path.name.startswith(".") + ) + manifest = { + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "core": git(core_path, "rev-parse", "HEAD"), + "collection": git(collection_path, "rev-parse", "HEAD"), + "core_tree": git(core_path, "rev-parse", "HEAD^{tree}"), + "collection_tree": git(collection_path, "rev-parse", "HEAD^{tree}"), + "playerbots_tree": git(collection_path, "rev-parse", "HEAD:playerbots"), + "core_dirty": False, + "collection_dirty": False, + "rust": rust_detail.split()[1], + "rust_detail": rust_detail, + "spacetimedb": spacetime_detail.split("spacetimedb tool version ", 1)[1].split(";", 1)[0], + "spacetimedb_detail": spacetime_detail, + "target": "wasm32-unknown-unknown", + "profile": "release", + "features": ["debug_reducers"], + "installed_packages": installed_packages, + "wasm_bytes": len(wasm), + "wasm_sha256": hashlib.sha256(wasm).hexdigest(), + "package_content_identity": "33fcb8a217aad84f46f9ad65efdad23be24bad8e29828777de7c7e6469417161", + "verification": "Build input from clean merged PB-002 checkouts. The consumer verifies the source identities, Package content, and this environment's Wasm digest before publish and populated upgrade.", + } + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + PY echo "PLAYERBOTS_COMPANION_PRECEDING_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" + echo "PLAYERBOTS_COMPANION_PRECEDING_MANIFEST=$RUNNER_TEMP/playerbots-pb002-preceding-inputs.json" >> "$GITHUB_ENV" + echo "PLAYERBOTS_COMPANION_PRECEDING_CORE=$GITHUB_WORKSPACE/preceding-pb002-core" >> "$GITHUB_ENV" + echo "PLAYERBOTS_COMPANION_PRECEDING_COLLECTION=$GITHUB_WORKSPACE/preceding-pb002-collection" >> "$GITHUB_ENV" - name: Run the real Module and Package action fixture env: CARGO_TARGET_DIR: ${{ github.workspace }}/lyracore/target run: | cargo clean --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" -p lyracore-module ./collection/.github/check-core-tip.sh "$GITHUB_WORKSPACE/lyracore" \ - cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ - -p lyracore-module --test playerbots_rewrite --test playerbots_companion \ - -- --ignored --test-threads=1 + bash -eu -o pipefail -c ' + cargo build --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ + --locked --release --target wasm32-unknown-unknown \ + -p lyracore-module --features=debug_reducers + cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ + -p lyracore-module --test playerbots_rewrite --test playerbots_companion \ + --no-fail-fast -- --ignored --test-threads=1 + ' - name: Upload fixture inputs and Standalone logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 From 02e49438dd29dd8417ff64341b951735477ae6f8 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:02:06 +0000 Subject: [PATCH 06/16] fix(playerbots): retain grouped self recovery --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 16 ++++++++++------ playerbots/src/runner.rs | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index f0964bc..774b73b 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 5433d21e3eaeaefccbc7f9921bddd2bc3b3c85a8 + LYRACORE_REF: fee614ecbad5cc512a9e1c2d7f8a1e830c74816e concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 4ac5de4..43c2023 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -390,11 +390,7 @@ pub fn playerbots_fixture_companion_mixed_heals( ctx.db.game_spell().spell_id().delete(CHANNEL_HEAL); ctx.db.game_spell().insert(channel); let effects = ctx.db.game_spell_effect(); - for row in effects - .by_spell() - .filter(&CHANNEL_HEAL) - .collect::>() - { + for row in effects.by_spell().filter(&CHANNEL_HEAL).collect::>() { effects.id().delete(row.id); } for mut effect in effects.by_spell().filter(&HEAL).collect::>() { @@ -409,8 +405,16 @@ pub fn playerbots_fixture_companion_mixed_heals( CHANNEL_HEAL, ); let rotations = ctx.db.pkg_playerbots_rotation(); + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(character_guid) + .next() + .ok_or("companion bot missing")?; let mut supported = rotations - .iter() + .by_class_role() + .filter((bot.class, bot.role)) .find(|row| row.spell_id == HEAL) .ok_or("supported heal rotation missing")?; supported.priority = 1; diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 387c89f..285612d 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -1040,7 +1040,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n )); } else { strategies.push(strategy(Trigger::LowHealth, survival)); - if party.is_none() { + if party.is_none() || bot.role != super::ROLE_HEALER { strategies.push(strategy(Trigger::Wounded, recovery)); } strategies.push(strategy(Trigger::Attacked, defense)); From b63d01bfec8bdeb04caf26ae6e24aae17ed4a860 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:16:00 +0000 Subject: [PATCH 07/16] fix(ci): run playerbots build from core checkout --- .github/workflows/core-tip.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 774b73b..345f889 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -274,11 +274,12 @@ jobs: echo "PLAYERBOTS_COMPANION_PRECEDING_CORE=$GITHUB_WORKSPACE/preceding-pb002-core" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_COLLECTION=$GITHUB_WORKSPACE/preceding-pb002-collection" >> "$GITHUB_ENV" - name: Run the real Module and Package action fixture + working-directory: lyracore env: CARGO_TARGET_DIR: ${{ github.workspace }}/lyracore/target run: | cargo clean --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" -p lyracore-module - ./collection/.github/check-core-tip.sh "$GITHUB_WORKSPACE/lyracore" \ + "$GITHUB_WORKSPACE/collection/.github/check-core-tip.sh" "$GITHUB_WORKSPACE/lyracore" \ bash -eu -o pipefail -c ' cargo build --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ --locked --release --target wasm32-unknown-unknown \ From 0348d4e8e68f52fa0d0321b4f2de7d5f51cfac27 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:17:03 +0000 Subject: [PATCH 08/16] ci(playerbots): test prefixed companion cases --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 345f889..bad197a 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: fee614ecbad5cc512a9e1c2d7f8a1e830c74816e + LYRACORE_REF: 4b87f0a44c2fa4f08b8bcb05743b59e94136a0a6 concurrency: group: core-tip-${{ github.ref }} From 7c55df8d55fc30eab43e90f9ff92a38397db2b4d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:39:22 +0000 Subject: [PATCH 09/16] test(playerbots): teach the staged fixture heal --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index bad197a..cc82337 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 4b87f0a44c2fa4f08b8bcb05743b59e94136a0a6 + LYRACORE_REF: 2436053a820015f6eb21a4d70e7b2bd055c4d4b6 concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 43c2023..66f30d0 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -19,7 +19,7 @@ const COMPANION_GROUP: u64 = 5_090_300; pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let bots: Vec = ctx.db.pkg_playerbots_bot().iter().collect(); - for mut bot in bots { + for mut bot in bots.iter().cloned() { bot.next_think_micros = i64::MAX; ctx.db .game_creature_spline() @@ -62,6 +62,9 @@ pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { ctx.db.game_spell_effect().id().delete(effect.id); ctx.db.game_spell_effect().insert(effect); } + for bot in bots { + crate::spell::learn_spell(ctx, bot.character_guid, spacetimedb::Identity::ZERO, HEAL); + } Ok(()) } From 87f71666136c5fd088d6e0eeb032c622bf89ed7c Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:48:09 +0000 Subject: [PATCH 10/16] fix(playerbots): retain staged fixture actors --- playerbots/src/fixture.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 66f30d0..ebf3726 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -19,7 +19,8 @@ const COMPANION_GROUP: u64 = 5_090_300; pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let bots: Vec = ctx.db.pkg_playerbots_bot().iter().collect(); - for mut bot in bots.iter().cloned() { + let bot_guids: Vec<_> = bots.iter().map(|bot| bot.character_guid).collect(); + for mut bot in bots { bot.next_think_micros = i64::MAX; ctx.db .game_creature_spline() @@ -62,8 +63,8 @@ pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { ctx.db.game_spell_effect().id().delete(effect.id); ctx.db.game_spell_effect().insert(effect); } - for bot in bots { - crate::spell::learn_spell(ctx, bot.character_guid, spacetimedb::Identity::ZERO, HEAL); + for guid in bot_guids { + crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); } Ok(()) } From 93915a41124c5739ab7c6fe266709f85d8dfae6c Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:50:36 +0000 Subject: [PATCH 11/16] test(playerbots): remove redundant fixture grants --- playerbots/src/fixture.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index ebf3726..a96eaa4 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -236,8 +236,6 @@ pub fn playerbots_fixture_companion_stage( companion_unit(ctx, leader_guid, 1220.0, 1200.0, 100)?; companion_unit(ctx, ally_guid, 1222.0, 1200.0, 100)?; companion_creature(ctx, 5_090_302, 1204.0, 1204.0, 50.0, None)?; - crate::spell::learn_spell(ctx, companion_guid, spacetimedb::Identity::ZERO, HEAL); - crate::spell::learn_spell(ctx, leader_guid, spacetimedb::Identity::ZERO, HEAL); let rotations = ctx.db.pkg_playerbots_rotation(); for row in rotations .by_class_role() @@ -329,16 +327,6 @@ pub fn playerbots_fixture_companion_forget_heal( Ok(()) } -#[reducer] -pub fn playerbots_fixture_companion_learn_heal( - ctx: &ReducerContext, - guid: u64, -) -> Result<(), String> { - crate::helpers::require_operator(ctx)?; - crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); - Ok(()) -} - #[reducer] pub fn playerbots_fixture_companion_lesser_heal_target( ctx: &ReducerContext, @@ -996,7 +984,6 @@ pub fn playerbots_fixture_runner_stage( personality.flee_at_pct = 0; ctx.db.pkg_playerbots_personality().id().update(personality); if healing { - crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); let rows = ctx.db.pkg_playerbots_rotation(); for row in rows .by_class_role() From b32769e17ccb6b8ba6e936c17ae1e09b01f62617 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:02:56 +0000 Subject: [PATCH 12/16] ci(playerbots): test corrected companion core --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index cc82337..92a12cf 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 2436053a820015f6eb21a4d70e7b2bd055c4d4b6 + LYRACORE_REF: 0db3ac48b0fa69d6c4381e66f045a425909b839d concurrency: group: core-tip-${{ github.ref }} From b9d09f9c6ab0033c66c530642412f98a6dc3f57d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:16:17 +0000 Subject: [PATCH 13/16] ci(playerbots): test observed companion movement --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 92a12cf..478802f 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 0db3ac48b0fa69d6c4381e66f045a425909b839d + LYRACORE_REF: 8306ccb4c5951fe4c39dee69717e446ad8af0bde concurrency: group: core-tip-${{ github.ref }} From 87468ec4eaf2d63fdefe754c9edeb3ad65fc71d1 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:47:28 +0000 Subject: [PATCH 14/16] test(playerbots): park explicit companion passes --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 478802f..e11e69b 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 8306ccb4c5951fe4c39dee69717e446ad8af0bde + LYRACORE_REF: b3bf5a7e91536c7dc2f7cfb90305e6d1f35889f6 concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index a96eaa4..6f47ae6 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1043,6 +1043,26 @@ pub fn playerbots_fixture_runner_pass(ctx: &ReducerContext) -> Result<(), String Ok(()) } +/// Make one bot due, run the real Bot Controller pass, then keep background ticks from running its +/// next step before the fixture inspects the result. +#[reducer] +pub fn playerbots_fixture_runner_pass_once(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + runner_due_for(ctx, guid)?; + super::runner::pass(ctx); + use super::pkg_playerbots_scheduler; + let processed = ctx + .db + .pkg_playerbots_scheduler() + .id() + .find(0) + .is_some_and(|row| row.processed_guids.contains(&guid)); + if !processed { + return Err(format!("runner pass did not process bot {guid}")); + } + runner_park_for(ctx, guid) +} + #[reducer] pub fn playerbots_fixture_runner_survival(ctx: &ReducerContext, guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1113,6 +1133,19 @@ fn runner_due_for(ctx: &ReducerContext, guid: u64) -> Result<(), String> { Ok(()) } +fn runner_park_for(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + let mut bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + bot.next_think_micros = i64::MAX; + ctx.db.pkg_playerbots_bot().id().update(bot); + Ok(()) +} + #[reducer] pub fn playerbots_fixture_companion_due(ctx: &ReducerContext, guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; From 4741c973910a78127bf722bde274c79556b75306 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:15:52 +0000 Subject: [PATCH 15/16] test(playerbots): park companion controller selection --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index e11e69b..3d55c19 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: b3bf5a7e91536c7dc2f7cfb90305e6d1f35889f6 + LYRACORE_REF: 624edcdfde9c607a22c9ddc751b7c40caf6c7018 concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 6f47ae6..ebcde82 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1063,6 +1063,18 @@ pub fn playerbots_fixture_runner_pass_once(ctx: &ReducerContext, guid: u64) -> R runner_park_for(ctx, guid) } +/// Select controlled companion behavior without opening a scheduler gap before the fixture's first +/// explicit runner pass. +#[reducer] +pub fn playerbots_fixture_runner_select_cohort( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + super::runner::playerbots_select_controller(ctx, guid, super::Controller::Cohort)?; + runner_park_for(ctx, guid) +} + #[reducer] pub fn playerbots_fixture_runner_survival(ctx: &ReducerContext, guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; From 7f23a4ba93eae5f560c63960b49622cde6042b6d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:36:00 +0000 Subject: [PATCH 16/16] ci(playerbots): test merged companion support on core main --- .github/workflows/core-tip.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 3d55c19..a32c4d6 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -8,9 +8,6 @@ on: permissions: contents: read -env: - LYRACORE_REF: 624edcdfde9c607a22c9ddc751b7c40caf6c7018 - concurrency: group: core-tip-${{ github.ref }} cancel-in-progress: true @@ -43,11 +40,11 @@ jobs: path: collection persist-credentials: false - - name: Check out paired LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false @@ -69,11 +66,11 @@ jobs: path: collection persist-credentials: false - - name: Check out paired LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false @@ -133,11 +130,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out paired LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target