diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 4ba5522..a32c4d6 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,108 @@ 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" + 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 + 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" \ - cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ - -p lyracore-module --test playerbots_rewrite -- --ignored --test-threads=1 + "$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 \ + -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 diff --git a/playerbots/README.md b/playerbots/README.md index 1bd8a21..2dc250a 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -421,13 +421,26 @@ 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 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. 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 +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 +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 @@ -452,14 +465,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 +482,19 @@ 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 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 +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 +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..2caea14 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", @@ -31,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 new file mode 100644 index 0000000..6ebe34a --- /dev/null +++ b/playerbots/src/companion.rs @@ -0,0 +1,257 @@ +//! 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, +) -> Result, crate::group::PartyFactsUnavailable> { + let Some(facts) = crate::group::party_facts(ctx, character_guid)? else { + return Ok(None); + }; + if ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(facts.leader_guid) + .next() + .is_some() + { + return Ok(None); + } + let leader = facts + .members + .iter() + .find(|member| member.character_guid == facts.leader_guid) + .and_then(|member| member.unit.clone()); + Ok(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_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| 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, + me: &crate::WorldEntity, + party: &Party, + heal_spell: Option<&PlayerbotsRotation>, + survival_permits_healing: bool, + objective: u64, + retained_heal_target: Option, +) -> CompanionSelection { + 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_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 = heal_target?; + 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); + CompanionSelection { + strategy: Strategy { + trigger: Trigger::Always, + candidates, + defaults: vec![], + priority_adjustment: 0, + }, + heal_target, + } +} + +#[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, 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 6d8a85e..59f316f 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,11 @@ pub enum Reason { Defense, ReturnHome, Idle, + Follow, + 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 0090d0b..ebcde82 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1,21 +1,25 @@ //! 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, + 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] pub fn playerbots_fixture_prepare(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let bots: Vec = ctx.db.pkg_playerbots_bot().iter().collect(); + 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 @@ -55,9 +59,13 @@ 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); } + for guid in bot_guids { + crate::spell::learn_spell(ctx, guid, spacetimedb::Identity::ZERO, HEAL); + } Ok(()) } @@ -131,6 +139,403 @@ 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)?; + 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_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, + 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_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_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 bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(character_guid) + .next() + .ok_or("companion bot missing")?; + let mut supported = rotations + .by_class_role() + .filter((bot.class, bot.role)) + .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, + 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)?; @@ -579,8 +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); - use super::pkg_playerbots_rotation; let rows = ctx.db.pkg_playerbots_rotation(); for row in rows .by_class_role() @@ -640,6 +1043,38 @@ 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) +} + +/// 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)?; @@ -710,6 +1145,25 @@ 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)?; + runner_due_for(ctx, guid) +} + #[reducer] pub fn playerbots_fixture_runner_wide_recovery( ctx: &ReducerContext, @@ -734,7 +1188,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..285612d 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; @@ -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)] @@ -73,6 +74,7 @@ pub enum Failure { ActionRefused(crate::actor::ActionRefusalKind), CastRefused(crate::spell::CastRefusalKind), Decision(DecisionRefusal), + PartyFactsUnavailable, } #[derive(spacetimedb::SpacetimeType, Clone, Debug)] @@ -189,6 +191,12 @@ 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, + /// 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) { @@ -268,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; @@ -360,6 +374,8 @@ impl PlayerbotsRunner { transitions: 0, route_expansions: 0, route_budget: 0, + companion_leader_guid: None, + companion_heal_target_guid: None, } } @@ -544,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 { @@ -603,37 +626,91 @@ 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.companion_heal_target_guid = None; 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 +721,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 +868,53 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n state.save(ctx); return; }; + 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, &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 +937,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 +996,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 +1027,46 @@ 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), + )]; + let mut companion_heal_target = state.companion_heal_target_guid; + 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() || bot.role != super::ROLE_HEALER { + strategies.push(strategy(Trigger::Wounded, recovery)); + } + strategies.push(strategy(Trigger::Attacked, defense)); + if let Some(party) = party.as_ref() { + let selection = super::companion::strategy( + ctx, + bot, + &me, + party, + 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)); + } + } + strategies.push(strategy( + 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; @@ -947,7 +1082,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() { @@ -974,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; @@ -1004,7 +1149,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 +1160,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 +1172,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 +1197,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 +1233,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; } } @@ -1146,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 @@ -1172,6 +1337,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, + ), + } + } } } @@ -1189,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);