diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index cb93caa..4ba5522 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Check out Packages uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Test and enforce the no-committed-Package-Delta firewall run: | @@ -36,6 +38,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: collection + persist-credentials: false - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,6 +46,7 @@ jobs: repository: LyraCoreProject/LyraCore ref: main path: lyracore + persist-credentials: false - name: Install Rust 1.93.0 run: rustup toolchain install 1.93.0 --profile minimal @@ -60,6 +64,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: collection + persist-credentials: false - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -67,6 +72,7 @@ jobs: repository: LyraCoreProject/LyraCore ref: main path: lyracore + persist-credentials: false # `spacetime generate` builds the module to wasm and extracts its schema through it, so this # job needs the same toolchain + target the `module` job's compile check needs, plus the @@ -146,6 +152,33 @@ jobs: tar --extract --gzip --file "$archive_path" --directory "$cli_dir" ln -s spacetimedb-cli "$cli_dir/spacetime" echo "$cli_dir" >> "$GITHUB_PATH" + - name: Check out the preceding Module source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LyraCoreProject/LyraCore + ref: be3fa67d0f0c24749230560544a3e8e8b577f61d + path: preceding-core + persist-credentials: false + - name: Check out the preceding Package source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LyraCoreProject/packages + ref: 5724de1660a2628e88320914bdd5abb0c69da517 + path: preceding-collection + persist-credentials: false + - name: Build the pinned preceding Wasm for populated migration + working-directory: preceding-core + env: + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + run: | + export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/preceding-core/target" + cargo clean --manifest-path "$GITHUB_WORKSPACE/preceding-core/Cargo.toml" -p lyracore-module + "$GITHUB_WORKSPACE/preceding-collection/.github/check-core-tip.sh" "$GITHUB_WORKSPACE/preceding-core" \ + cargo build --manifest-path "$GITHUB_WORKSPACE/preceding-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-preceding.wasm" + echo "PLAYERBOTS_PRECEDING_WASM=$RUNNER_TEMP/playerbots-preceding.wasm" >> "$GITHUB_ENV" - name: Run the real Module and Package action fixture run: | cargo clean --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" -p lyracore-module diff --git a/playerbots/README.md b/playerbots/README.md index ececc6d..1bd8a21 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -397,3 +397,80 @@ storage and reward exchanges, including bank holdings and other rewards in the s observation establishes its baseline without clearing an existing stall. Later increases count as progress. The fixture exercises an existing stalled goal with nonzero objective credit and this migration default; it does not claim to publish an older Package binary before upgrading it. + +## Durable controller and objective + +`playerbots_select_controller(guid, controller)` selects one controller for that Character. The +SpacetimeDB argument names are `legacy`, `recordOnly`, `cohort`, and `frozen`. + +| Controller | Behavior | +| --- | --- | +| Legacy | Existing quest and party policy. This is the additive migration default. | +| RecordOnly | Records the new decision without issuing gameplay from either controller. | +| Cohort | Runs the durable objective and typed action runner. | +| Frozen | Cancels Package casts and movement, stops bot attacks, and prevents new bot work. | + +Selection also updates core-owned session-less action consent. Legacy and Cohort allow group +admission; RecordOnly and Frozen suppress it. Every selection clears this Character's unclaimed +Group Intents, including a repeated selection. A group action admitted before the selection may +finish at Realm-core afterwards. There is no atomic operation across Shards. + +Every gameplay entry checks current Account Claim and Fence ownership and Character World Session +status. A human taking ownership suspends the bot even before `online` changes. Cancellation matches +the Package cast identity or movement start time, so it preserves a human's later work. Bot control +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. + +Recovery scans at most 24 indexed healing rotation rows and revalidates at most two retained +rows per pass. 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 +scans restart on the next pass, so inserted or edited rows are reconsidered. A retained +spell is checked against its current class, role, condition, and exact learned-spell row +before use. Defense in this controller responds to the live attacker recorded by incoming +damage; target acquisition and assistance remain downstream work. + +The normal Package tick reads the `(next_think_micros, id)` index and processes at most 16 due bots. +Excess bots keep their due time. They precede bots whose turn already advanced the clock. The +scheduler row reports the processed identities and the oldest deferred lag. Runner rows backfill +only for that bounded batch, or for one explicitly selected bot. + +`pkg_playerbots_runner` is the explanation read. It reports the retained objective and destination, +chosen target and reason, ordered candidates, foreground cast or movement, latest outcome, progress +age at `observed_micros`, retry count, next eligible time, and catalog revision. It retains eight +meaningful transitions, four failures and four deferred destinations per Character. Movement, +combat, cast and quest evidence have separate clocks. An accepted attack does not advance any of +them. A completed cast records dispatched effects, and does not complete the return-home objective. + +One foreground action retains the controller generation and partition. A higher-priority action +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. + +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. + +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. +The roster selector travels with the Character; runner observations and foreground work do not. +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 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 +`PLAYERBOTS_PRECEDING_WASM` to that path. Package CI performs this build automatically. The case +publishes that preceding Wasm, populates real bot, goal, quest and action rows, then upgrades the +same private Standalone to the current Wasm. It records both Wasm identities and asserts the old +rows survive, selector defaults apply, and backfill takes bounded passes. diff --git a/playerbots/src/actions.rs b/playerbots/src/actions.rs index 7e16044..3e92ca1 100644 --- a/playerbots/src/actions.rs +++ b/playerbots/src/actions.rs @@ -51,7 +51,11 @@ crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_action(ctx, chara }); crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_action()); -fn prior(ctx: &ReducerContext, guid: u64, kind: ActionKind) -> Option { +pub(super) fn observation( + ctx: &ReducerContext, + guid: u64, + kind: ActionKind, +) -> Option { ctx.db .pkg_playerbots_action() .by_character() @@ -61,7 +65,7 @@ fn prior(ctx: &ReducerContext, guid: u64, kind: ActionKind) -> Option ActionOutcome::CastResolved, @@ -241,7 +245,7 @@ pub(super) fn movement( ) { let now = ctx.timestamp.to_micros_since_unix_epoch(); let mut last_advance_micros = None; - if let Some(before) = prior(ctx, guid, ActionKind::Move) { + if let Some(before) = observation(ctx, guid, ActionKind::Move) { if let ActionOutcome::Movement(previous) = before.outcome { if previous.map_id == map_id && previous.instance_id == instance_id diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs new file mode 100644 index 0000000..6d8a85e --- /dev/null +++ b/playerbots/src/decision.rs @@ -0,0 +1,560 @@ +//! Pure, bounded selection from typed Strategies and explicit facts. + +use crate::nav::LEG_MAX_EXPANSIONS; + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Action { + Hold, + Move(MoveTarget), + Cast(CastAction), + Attack(u64), +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CastAction { + pub target: u64, + pub spell: u32, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum MoveTarget { + Home, + Entity(u64), +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Reason { + Restricted, + Survival, + Recovery, + Defense, + ReturnHome, + Idle, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CandidateId { + pub action: Action, + pub reason: Reason, + pub objective: u64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub struct Candidate { + pub id: CandidateId, + pub priority: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Readiness { + Ready, + Complete, + Refused, + NotBefore(i64), +} + +#[derive(Clone, Debug)] +pub struct ActionNode { + pub candidate: Candidate, + pub readiness: Readiness, + pub prerequisites: Vec, + pub alternatives: Vec, + pub continuers: Vec, +} + +impl ActionNode { + pub fn ready(action: Action, reason: Reason, priority: i32) -> Self { + Self { + candidate: Candidate { + id: CandidateId { + action, + reason, + objective: 0, + }, + priority, + }, + readiness: Readiness::Ready, + prerequisites: Vec::new(), + alternatives: Vec::new(), + continuers: Vec::new(), + } + } +} + +#[derive(Clone, Copy)] +pub enum Trigger { + Restricted, + LowHealth, + Wounded, + Attacked, + Away, + Always, +} + +pub struct Facts { + pub now: i64, + pub restricted: bool, + pub low_health: bool, + pub wounded: bool, + pub attacked: bool, + pub away: bool, +} + +impl Trigger { + fn active(self, facts: &Facts) -> bool { + match self { + Self::Restricted => facts.restricted, + Self::LowHealth => facts.low_health, + Self::Wounded => facts.wounded, + Self::Attacked => facts.attacked, + Self::Away => facts.away, + Self::Always => true, + } + } +} + +pub struct Strategy { + pub trigger: Trigger, + pub candidates: Vec, + pub defaults: Vec, + pub priority_adjustment: i32, +} + +#[derive(Clone, Copy)] +pub struct Limits { + pub candidates: usize, + pub depth: usize, + pub transitions: usize, + pub route_expansions: u32, +} + +pub const LIMITS: Limits = Limits { + candidates: 24, + depth: 4, + transitions: 16, + route_expansions: LEG_MAX_EXPANSIONS, +}; + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum DecisionRefusal { + Candidates, + Depth, + Transitions, + RouteWork, + Cycle, + Unavailable, +} + +pub struct Decision { + pub order: Vec, + pub chosen: Option, + pub refusals: Vec, + pub transitions: usize, + pub route_expansions: u32, +} + +struct Search { + limits: Limits, + visited: usize, + transitions: usize, + route_expansions: u32, + refusals: Vec, + refusal_count: usize, +} + +impl Search { + fn refuse(&mut self, reason: DecisionRefusal) { + self.refusal_count += 1; + if !self.refusals.contains(&reason) { + self.refusals.push(reason); + } + } + + fn resolve( + &mut self, + node: &ActionNode, + now: i64, + path: &mut Vec, + ) -> Option { + if self.visited >= self.limits.candidates { + self.refuse(DecisionRefusal::Candidates); + return None; + } + self.visited += 1; + if self.transitions >= self.limits.transitions { + self.refuse(DecisionRefusal::Transitions); + return None; + } + self.transitions += 1; + if path.contains(&node.candidate.id) { + self.refuse(DecisionRefusal::Cycle); + return None; + } + if path.len() >= self.limits.depth { + self.refuse(DecisionRefusal::Depth); + return None; + } + path.push(node.candidate.id); + let chosen = match node.readiness { + Readiness::Complete => self.first(&node.continuers, now, path), + Readiness::Refused => self.first(&node.alternatives, now, path), + Readiness::NotBefore(at) if at > now => self.first(&node.alternatives, now, path), + Readiness::Ready | Readiness::NotBefore(_) => { + let mut pending = None; + let mut refused = false; + for prerequisite in &node.prerequisites { + let refusals_before = self.refusal_count; + pending = self.resolve(prerequisite, now, path); + if pending.is_none() + && prerequisite.readiness == Readiness::Complete + && self.refusal_count == refusals_before + { + continue; + } + refused = pending.is_none(); + break; + } + if refused { + self.first(&node.alternatives, now, path) + } else if let Some(mut candidate) = pending { + candidate.priority = candidate.priority.max(node.candidate.priority); + Some(candidate) + } else if matches!(node.candidate.id.action, Action::Move(_)) + && self.limits.route_expansions < LEG_MAX_EXPANSIONS + { + self.refuse(DecisionRefusal::RouteWork); + self.first(&node.alternatives, now, path) + } else { + if matches!(node.candidate.id.action, Action::Move(_)) { + self.route_expansions = LEG_MAX_EXPANSIONS; + } + Some(node.candidate) + } + } + }; + path.pop(); + chosen + } + + fn first( + &mut self, + nodes: &[ActionNode], + now: i64, + path: &mut Vec, + ) -> Option { + for node in nodes { + if let Some(candidate) = self.resolve(node, now, path) { + return Some(candidate); + } + if self.visited >= self.limits.candidates || self.transitions >= self.limits.transitions + { + break; + } + } + None + } +} + +/// Candidate identity includes its target, spell and trigger. Equal facts and time have no random input. +/// Limits apply across all active Strategies, including failed prerequisite and fallback branches. +pub fn choose(facts: &Facts, strategies: &[Strategy], limits: Limits) -> Decision { + let mut roots = Vec::new(); + let mut overflow = false; + 'strategies: for strategy in strategies { + let nodes = if strategy.trigger.active(facts) { + &strategy.candidates + } else { + &strategy.defaults + }; + for node in nodes { + if roots.len() == limits.candidates { + overflow = true; + break 'strategies; + } + roots.push(( + node, + node.candidate + .priority + .saturating_add(strategy.priority_adjustment), + )); + } + } + roots.sort_by_key(|(node, priority)| (std::cmp::Reverse(*priority), node.candidate.id)); + let mut identities = std::collections::BTreeSet::new(); + roots.retain(|(node, _)| identities.insert(node.candidate.id)); + let order = roots + .iter() + .map(|(node, priority)| Candidate { + priority: *priority, + ..node.candidate + }) + .collect(); + let mut search = Search { + limits, + visited: 0, + transitions: 0, + route_expansions: 0, + refusals: Vec::new(), + refusal_count: 0, + }; + if overflow { + search.refuse(DecisionRefusal::Candidates); + } + let mut chosen = None; + for (node, priority) in roots { + if let Some(mut candidate) = search.resolve(node, facts.now, &mut Vec::new()) { + candidate.priority = priority; + chosen = Some(candidate); + break; + } + if search.visited >= limits.candidates || search.transitions >= limits.transitions { + break; + } + } + if chosen.is_none() { + search.refuse(DecisionRefusal::Unavailable); + } + Decision { + order, + chosen, + refusals: search.refusals, + transitions: search.transitions, + route_expansions: search.route_expansions, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts() -> Facts { + Facts { + now: 100, + restricted: false, + low_health: false, + wounded: false, + attacked: true, + away: true, + } + } + fn strategy(nodes: Vec) -> Strategy { + Strategy { + trigger: Trigger::Always, + candidates: nodes, + defaults: vec![], + priority_adjustment: 0, + } + } + fn attack(target: u64) -> ActionNode { + ActionNode::ready(Action::Attack(target), Reason::Defense, 50) + } + + #[test] + fn fixed_facts_rank_targets_and_priorities_independent_of_input_order() { + for targets in [[22, 11], [11, 22]] { + let result = choose( + &facts(), + &[strategy(targets.into_iter().map(attack).collect())], + LIMITS, + ); + assert_eq!( + result.order.iter().map(|c| c.id.action).collect::>(), + vec![Action::Attack(11), Action::Attack(22)] + ); + assert_eq!(result.chosen.unwrap().id.action, Action::Attack(11)); + } + } + + #[test] + fn strategies_compose_triggers_defaults_and_priority() { + let mut defense = strategy(vec![attack(11)]); + defense.priority_adjustment = 40; + let survival = Strategy { + trigger: Trigger::LowHealth, + candidates: vec![ActionNode::ready( + Action::Move(MoveTarget::Home), + Reason::Survival, + 100, + )], + defaults: vec![ActionNode::ready(Action::Hold, Reason::Idle, 1)], + priority_adjustment: 0, + }; + let mut f = facts(); + let strategies = [defense, survival]; + assert_eq!( + choose(&f, &strategies, LIMITS).chosen.unwrap().id.action, + Action::Attack(11) + ); + f.low_health = true; + assert_eq!( + choose(&f, &strategies, LIMITS).chosen.unwrap().id.reason, + Reason::Survival + ); + } + + #[test] + fn prerequisites_keep_the_target_and_continuers_require_completion() { + let mut node = attack(22); + node.prerequisites.push(ActionNode::ready( + Action::Move(MoveTarget::Entity(22)), + Reason::Defense, + 1, + )); + let result = choose(&facts(), &[strategy(vec![node.clone()])], LIMITS); + assert_eq!( + result.chosen.unwrap(), + Candidate { + id: CandidateId { + action: Action::Move(MoveTarget::Entity(22)), + reason: Reason::Defense, + objective: 0 + }, + priority: 50 + } + ); + node.prerequisites[0].readiness = Readiness::Complete; + node.continuers.push(ActionNode::ready( + Action::Move(MoveTarget::Home), + Reason::ReturnHome, + 10, + )); + assert_eq!( + choose(&facts(), &[strategy(vec![node.clone()])], LIMITS) + .chosen + .unwrap() + .id + .action, + Action::Attack(22) + ); + node.readiness = Readiness::Complete; + assert_eq!( + choose(&facts(), &[strategy(vec![node])], LIMITS) + .chosen + .unwrap() + .id + .reason, + Reason::ReturnHome + ); + } + + #[test] + fn repeated_cycle_refusals_cannot_satisfy_a_completed_prerequisite() { + let mut first = attack(1); + first.readiness = Readiness::Complete; + first.candidate.priority = 100; + first.continuers.push(attack(1)); + let mut prerequisite = attack(3); + prerequisite.readiness = Readiness::Complete; + prerequisite.continuers.push(attack(3)); + let mut second = attack(2); + second.prerequisites.push(prerequisite); + let result = choose(&facts(), &[strategy(vec![first, second])], LIMITS); + assert!(result.chosen.is_none()); + assert!(result.refusals.contains(&DecisionRefusal::Cycle)); + } + + #[test] + fn cycles_refuse_boundedly() { + let mut cycle = attack(11); + cycle.prerequisites.push(attack(11)); + assert!(choose(&facts(), &[strategy(vec![cycle])], LIMITS) + .refusals + .contains(&DecisionRefusal::Cycle)); + } + + #[test] + fn each_work_limit_refuses_boundedly() { + for (limits, refusal) in [ + ( + Limits { + candidates: 0, + ..LIMITS + }, + DecisionRefusal::Candidates, + ), + (Limits { depth: 0, ..LIMITS }, DecisionRefusal::Depth), + ( + Limits { + transitions: 0, + ..LIMITS + }, + DecisionRefusal::Transitions, + ), + ( + Limits { + route_expansions: 0, + ..LIMITS + }, + DecisionRefusal::RouteWork, + ), + ] { + let result = choose( + &facts(), + &[strategy(vec![ActionNode::ready( + Action::Move(MoveTarget::Home), + Reason::ReturnHome, + 1, + )])], + limits, + ); + assert!(result.chosen.is_none()); + assert!(result.refusals.contains(&refusal)); + } + } + + #[test] + fn refused_actions_use_alternatives_and_time_is_explicit() { + let mut node = attack(11); + node.readiness = Readiness::Refused; + let mut fallback = ActionNode::ready(Action::Hold, Reason::Idle, 1); + fallback.readiness = Readiness::NotBefore(101); + node.alternatives.push(fallback); + let strategies = [strategy(vec![node])]; + assert!(choose(&facts(), &strategies, LIMITS).chosen.is_none()); + let mut later = facts(); + later.now = 101; + assert_eq!( + choose(&later, &strategies, LIMITS) + .chosen + .unwrap() + .id + .action, + Action::Hold + ); + } + #[test] + fn a_candidate_keeps_only_its_highest_priority_offer() { + let mut high = attack(11); + high.candidate.priority = 100; + let result = choose( + &facts(), + &[strategy(vec![attack(11), attack(22), high])], + LIMITS, + ); + assert_eq!(result.order.len(), 2); + assert_eq!(result.order[0].priority, 100); + assert_eq!(result.order[0].id.action, Action::Attack(11)); + } + + #[test] + fn completed_prerequisites_still_consume_transition_work() { + let mut node = attack(11); + for target in 20..40 { + let mut prerequisite = attack(target); + prerequisite.readiness = Readiness::Complete; + node.prerequisites.push(prerequisite); + } + let result = choose(&facts(), &[strategy(vec![node])], LIMITS); + assert!(result.chosen.is_none()); + assert_eq!(result.transitions, LIMITS.transitions); + assert!(result.refusals.contains(&DecisionRefusal::Transitions)); + } + #[test] + fn a_strategy_can_lower_an_offers_priority() { + let mut defense = strategy(vec![attack(11)]); + defense.priority_adjustment = -40; + let result = choose(&facts(), &[defense], LIMITS); + assert_eq!(result.order[0].priority, 10); + assert_eq!(result.chosen.unwrap().priority, 10); + } +} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 01a869e..0090d0b 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1,4 +1,5 @@ -//! Deterministic staging for the private durable action fixture. +//! 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}; @@ -204,8 +205,8 @@ pub fn playerbots_fixture_blocked_quest(ctx: &ReducerContext, guid: u64) -> Resu crate::actor::stage_quest(ctx, guid, 50909)?; let cx = lyracore_shared::terrain::cell_index(me.x).ok_or("fixture off grid")?; let cy = lyracore_shared::terrain::cell_index(me.y).ok_or("fixture off grid")?; - for x in cx - 1..=cx + 1 { - for y in cy - 1..=cy + 1 { + for x in cx.saturating_sub(1)..=cx.saturating_add(1).min(1023) { + for y in cy.saturating_sub(1)..=cy.saturating_add(1).min(1023) { let key = lyracore_shared::terrain::cell_key(me.map_id, x, y); ctx.db.game_nav_chunk().key().delete(key); ctx.db.game_nav_chunk().insert(crate::nav::NavChunk { @@ -535,3 +536,226 @@ pub fn playerbots_fixture_credit_kill(ctx: &ReducerContext, guid: u64) -> Result } Ok(()) } + +#[reducer] +pub fn playerbots_fixture_runner_stage( + ctx: &ReducerContext, + guid: u64, + healing: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + bot.home_x = 1240.0; + bot.home_y = 1200.0; + bot.home_z = 50.0; + bot.home_map = 0; + bot.next_think_micros = i64::MAX; + let _ = crate::actor::stop_attack(ctx, guid); + use crate::game_threat; + let threats = ctx.db.game_threat(); + for row in threats.by_source().filter(guid).collect::>() { + threats.id().delete(row.id); + } + let mut me = crate::helpers::live_entity(ctx, guid)?; + me.health = if healing { + me.max_health / 4 + } else { + me.max_health + }; + ctx.db.game_world_entity().guid().update(me); + let mut personality = ctx + .db + .pkg_playerbots_personality() + .by_character() + .filter(guid) + .next() + .ok_or("personality missing")?; + 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() + .filter((bot.class, bot.role)) + .collect::>() + { + rows.id().delete(row.id); + } + rows.insert(super::PlayerbotsRotation { + id: 0, + class: bot.class, + role: bot.role, + priority: 0, + spell_id: HEAL, + condition: super::cond::ALLY_HP_BELOW_PCT, + threshold_pct: 50, + }); + } + ctx.db.pkg_playerbots_bot().id().update(bot); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_damage( + ctx: &ReducerContext, + guid: u64, + attacker: u64, + damage: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let (amount, _) = crate::combat::fold_incoming_damage(ctx, attacker, guid, damage); + let damage = crate::combat::final_damage(ctx, guid, amount); + crate::combat::apply_hit( + ctx, + attacker, + guid, + damage, + crate::combat::Hit::weapon(crate::combat::HitSource::MainHand, false), + ); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_due(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + for mut bot in ctx.db.pkg_playerbots_bot().iter().collect::>() { + bot.next_think_micros = ctx.timestamp.to_micros_since_unix_epoch() - 2_000_000; + ctx.db.pkg_playerbots_bot().id().update(bot); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_pass(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + super::runner::pass(ctx); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_survival(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut personality = ctx + .db + .pkg_playerbots_personality() + .by_character() + .filter(guid) + .next() + .ok_or("personality missing")?; + personality.flee_at_pct = 100; + ctx.db.pkg_playerbots_personality().id().update(personality); + runner_due_for(ctx, guid) +} + +#[reducer] +pub fn playerbots_fixture_runner_clear_navigation( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let me = crate::helpers::live_entity(ctx, guid)?; + let cx = lyracore_shared::terrain::cell_index(me.x).ok_or("fixture off grid")?; + let cy = lyracore_shared::terrain::cell_index(me.y).ok_or("fixture off grid")?; + for x in cx.saturating_sub(1)..=cx.saturating_add(1).min(1023) { + for y in cy.saturating_sub(1)..=cy.saturating_add(1).min(1023) { + ctx.db + .game_nav_chunk() + .key() + .delete(lyracore_shared::terrain::cell_key(me.map_id, x, y)); + } + } + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_expire_objective( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use super::pkg_playerbots_runner; + let mut state = ctx + .db + .pkg_playerbots_runner() + .character_guid() + .find(guid) + .ok_or("runner missing")?; + let objective = state.objective.as_mut().ok_or("objective missing")?; + objective.deadline_micros = ctx.timestamp.to_micros_since_unix_epoch(); + ctx.db + .pkg_playerbots_runner() + .character_guid() + .update(state); + runner_due_for(ctx, guid) +} + +fn runner_due_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 = ctx.timestamp.to_micros_since_unix_epoch() - 2_000_000; + ctx.db.pkg_playerbots_bot().id().update(bot); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_runner_wide_recovery( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + playerbots_fixture_runner_stage(ctx, guid, true)?; + use crate::game_player_spell; + let spells = ctx.db.game_player_spell(); + let heal = spells + .by_character_spell() + .filter((guid, HEAL)) + .next() + .ok_or("heal missing")?; + spells.id().delete(heal.id); + for spell_id in 5_091_000..5_091_300 { + spells.insert(crate::spell::PlayerSpell { + id: 0, + character_guid: guid, + owner_identity: heal.owner_identity, + spell_id, + }); + } + spells.insert(crate::spell::PlayerSpell { id: 0, ..heal }); + use super::pkg_playerbots_rotation; + let rotations = ctx.db.pkg_playerbots_rotation(); + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + let heal = rotations + .by_class_role() + .filter((bot.class, bot.role)) + .next() + .ok_or("rotation missing")?; + rotations.id().delete(heal.id); + for spell_id in 5_092_000..5_092_025 { + rotations.insert(super::PlayerbotsRotation { + id: 0, + spell_id, + ..heal + }); + } + rotations.insert(super::PlayerbotsRotation { id: 0, ..heal }); + Ok(()) +} diff --git a/playerbots/src/goals.rs b/playerbots/src/goals.rs index 7e1a6ba..a268884 100644 --- a/playerbots/src/goals.rs +++ b/playerbots/src/goals.rs @@ -1,28 +1,5 @@ -//! The bot mind: one tick pass, two notify hooks, and the movement a session-less Character needs -//! because no client is sending it any. -//! -//! WHAT DRIVES A BOT. A bot's `game_world_entity` row carries the PLAYER type mask, so the creature -//! behaviour cycle skips it — nothing in the core moves a bot, aggroes for it, or picks its spells. -//! This file is the whole of that. It runs on the core scheduler's tick pass, so there is no -//! Package-owned schedule row for a republish to leave pointing at a reducer that no longer exists. -//! -//! WHAT IT DOES NOT DO. Every action goes out through a core operation the player path also uses: -//! the actor verbs for attack, stop, cast and invite-accept, and the shared creature leg writer for -//! movement. The Package decides WHAT to do; the core decides whether it is allowed. -//! -//! CROSSING A SHARD BOUNDARY. A bot follows its party wherever the party goes, and on a realm of -//! several Shards that means a Transfer. The tick decides it and the Gateway drives it: the tick -//! writes one Transfer Intent, marks the bot in transit, and stops. The bot arrives with no live -//! entity and no goal, and the first tick there rebuilds it and falls it back in. Both halves are -//! this file; there is no arrival reducer. -//! -//! QUESTING. An ungrouped bot works quests around its home point: take one, kill what it names, -//! take what the kill leaves, hand it back. ONE rule decides whether a quest is worth walking to, -//! and it is the core's own rule rather than a copy of it: [`worth_the_walk`] asks -//! `crate::quest::accept_gates`, the same Gates `apply_accept_quest` applies, and -//! `crate::actor::accept_quest` is still the authority that answers for real. A bot that walked to -//! a giver and was refused would walk there again next second and forever; asking the core's own -//! Gate before the walk is what makes that impossible. +//! Legacy bot policy and the shared movement request. +//! The runner selects controllers and due work. Only Legacy bots enter this decision loop or its hooks. use spacetimedb::{ReducerContext, Table}; @@ -134,59 +111,15 @@ struct Party { type Partition = (u32, u64); crate::game_tick_pass!(fn playerbots_brain_pass(ctx) { - // The Package's own ensure path. A Shard that has just published, or a second Shard that has - // never run an Operator verb, seeds itself here rather than waiting to be told. - super::ensure_defaults(ctx); - let now = ctx.timestamp.to_micros_since_unix_epoch(); - let bots = ctx.db.pkg_playerbots_bot(); - let due: Vec = bots - .iter() - .filter(|bot| bot.next_think_micros <= now) - .collect(); - for mut bot in due { - think(ctx, &bot, now); - bot.next_think_micros = now + THINK_INTERVAL_MICROS; - bots.id().update(bot); - } + super::runner::pass(ctx); }); -// A group invite landed on a bot. A bot has no client to answer with, so the answer is server-side, -// through the same accept core a real client's accept reaches. -// -// PLANE NOTE: this hook fires where the invite row is written. On a realm whose party authority is a -// separate Shard the bot roster there is empty, this handler sees nothing, and the answer has to -// come from the Gateway instead. That is the Gateway's business, not the Package's. -crate::game_hook!(on_group_invite, fn playerbots_auto_accept(ctx, payload) { - if !is_bot(ctx, payload.target_guid) { - return; - } - // Named, not numbered. A bot has no client and no chat, so this line is the only record that an - // invite was answered at all, and a wall of 15-digit guids is not a record anyone can read. - // Deliberately NOT the in-transit-fenced lookup: a diagnostic wants the inviter's name even - // while they are mid-Transfer, and nothing on this path writes to their Character. - let inviter = ctx - .db - .game_character() - .guid() - .find(payload.inviter_guid) - .map(|character| character.name) - .unwrap_or_else(|| payload.inviter_guid.to_string()); - match crate::actor::accept_group_invite(ctx, payload.target_guid) { - Ok(()) => spacetimedb::log::info!( - "playerbots: bot {} accepted a party invite from {inviter}", - payload.target_guid - ), - Err(refusal) => spacetimedb::log::warn!( - "playerbots: bot {} could not accept the invite from {inviter}: {refusal}", - payload.target_guid - ), - } -}); + // A bot was hit. Hit back: a bot that stands still while something chews on it reads as broken long // before anyone notices it has no brain for that case. crate::game_hook!(on_damage_taken, fn playerbots_defend(ctx, payload) { - if payload.attacker_guid == 0 || !is_bot(ctx, payload.target_guid) { + if payload.attacker_guid == 0 || !super::runner::legacy_controls(ctx, payload.target_guid) { return; } if ctx @@ -218,7 +151,7 @@ fn is_bot(ctx: &ReducerContext, guid: u64) -> bool { // ---- the decision ---------------------------------------------------------------------------- -fn think(ctx: &ReducerContext, bot: &PlayerbotsBot, now: i64) { +pub(super) fn think(ctx: &ReducerContext, bot: &PlayerbotsBot, now: i64) { let Some(me) = body(ctx, bot, now) else { return; }; diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index e374aeb..3005c27 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -9,7 +9,7 @@ //! by name, and it refuses whispers. //! //! This file owns the roster: Accounts, names, the class/role tables, the Operator verbs, and the -//! despawn sweep. `goals.rs` owns the mind. +//! despawn sweep. `runner.rs` owns controller selection and due work; `goals.rs` retains Legacy policy. //! //! WHY NO SCHEDULE ROW: the bot mind runs on one `game_tick_pass!`, which the core scheduler owns. //! A Package that schedules itself writes a scheduled row, and a republish leaves that row pointing @@ -19,7 +19,10 @@ use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; mod actions; +mod decision; mod goals; +mod runner; +pub(crate) use runner::*; pub(crate) use actions::*; #[cfg(feature = "debug_reducers")] mod fixture; @@ -94,7 +97,8 @@ pub(crate) mod cond { #[table( accessor = pkg_playerbots_bot, public, - index(accessor = by_character, btree(columns = [character_guid])) + index(accessor = by_character, btree(columns = [character_guid])), + index(accessor = by_due, btree(columns = [next_think_micros, id])) )] pub struct PlayerbotsBot { #[primary_key] @@ -113,6 +117,10 @@ pub struct PlayerbotsBot { pub home_z: f32, /// Wall-clock microseconds before which the brain pass leaves this bot alone. pub next_think_micros: i64, + #[default(Controller::Legacy)] + pub controller: Controller, + #[default(0i64)] + pub scheduler_lag_micros: i64, } // A despawned bot takes its roster row with it. The row is keyed by `character_guid`, so the @@ -299,7 +307,8 @@ pub struct PlayerbotsKit { #[table( accessor = pkg_playerbots_rotation, public, - index(accessor = by_class_role, btree(columns = [class, role])) + index(accessor = by_class_role, btree(columns = [class, role])), + index(accessor = by_recovery_scan, btree(columns = [class, role, condition, id])) )] pub struct PlayerbotsRotation { #[primary_key] @@ -694,7 +703,10 @@ fn spawn_one( // lock-step for the rest of its life. next_think_micros: ctx.timestamp.to_micros_since_unix_epoch() + (guid % 10) as i64 * 100_000, + controller: Controller::Legacy, + scheduler_lag_micros: 0, }); + crate::actor::set_sessionless_action_consent(ctx, guid, true); Ok(guid) } diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs new file mode 100644 index 0000000..9bf5770 --- /dev/null +++ b/playerbots/src/runner.rs @@ -0,0 +1,1231 @@ +//! One controller, one retained objective, and one foreground action per bot. + +use super::actions; +use super::decision::{ + self, Action, ActionNode, Candidate, CastAction, DecisionRefusal, MoveTarget, Readiness, + Reason, Strategy, Trigger, +}; +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 spacetimedb::{reducer, table, ReducerContext, Table}; + +pub const BATCH_LIMIT: usize = 16; +const INTERVAL: i64 = 1_000_000; +const OBJECTIVE_LIFETIME: i64 = 120_000_000; +const STALL_INTERVAL: i64 = 10_000_000; +const DEFER_INTERVAL: i64 = 30_000_000; +const HISTORY_LIMIT: usize = 8; +const RECOVERY_SCAN_LIMIT: usize = 24; +const FAILURE_LIMIT: usize = 4; +const CATALOG_REVISION: u64 = 1; + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Controller { + Legacy, + RecordOnly, + Cohort, + Frozen, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] +pub struct Destination { + pub map_id: u32, + pub instance_id: u64, + pub x: f32, + pub y: f32, + pub z: f32, + pub geometry_revision: Option, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectiveKind { + ReturnHome, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectiveStage { + Travelling, + Completed, + Deferred, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct Objective { + pub identity: u64, + pub kind: ObjectiveKind, + pub destination: Destination, + pub stage: ObjectiveStage, + pub deadline_micros: i64, + pub last_verified_progress_micros: Option, + pub started_micros: i64, + pub catalog_revision: u64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Failure { + NoMovement, + DestinationUnavailable, + Deadline, + CastLost, + ActionRefused(crate::actor::ActionRefusalKind), + CastRefused(crate::spell::CastRefusalKind), + Decision(DecisionRefusal), +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct FailureRecord { + pub reason: Failure, + pub at_micros: i64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct DeferredDestination { + pub destination: Destination, + pub until_micros: i64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct MovementRun { + pub destination: Destination, + pub from_x: f32, + pub from_y: f32, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub enum Running { + Movement(MovementRun), + Cast(crate::spell::CastHandle), +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct Foreground { + pub candidate: Candidate, + pub generation: u64, + pub map_id: u32, + pub instance_id: u64, + pub started_micros: i64, + pub running: Running, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct MovementProgress { + pub observed_micros: i64, + pub x: f32, + pub y: f32, + pub arrived: bool, +} +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct CombatProgress { + pub observed_micros: i64, + pub target: u64, + pub health: u32, +} +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct CastProgress { + pub observed_micros: i64, + pub scheduled_id: u64, + pub spell: u32, + pub target: u64, +} +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct QuestProgress { + pub observed_micros: i64, + pub quest: u32, + pub credit: u64, + pub rewarded: bool, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub enum RunnerOutcome { + Initial, + Recorded, + Waiting, + Arrived, + Accepted, + CastFinished(crate::spell::CastFinish), + Refused(Failure), + Cancelled, + Frozen, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug)] +pub struct Transition { + pub at_micros: i64, + pub chosen: Option, + pub outcome: RunnerOutcome, +} + +/// Backfilled for at most BATCH_LIMIT due bots per pass. Legacy goals keep their stored meaning. +/// The public row is the explanation read; ages are relative to observed_micros. +#[table(accessor = pkg_playerbots_runner, public)] +pub struct PlayerbotsRunner { + #[primary_key] + pub character_guid: u64, + pub generation: u64, + pub objective_sequence: u64, + pub objective: Option, + pub foreground: Option, + pub chosen: Option, + pub candidate_order: Vec, + pub last_outcome: RunnerOutcome, + pub observed_micros: i64, + pub progress_age_micros: Option, + pub retry_count: u8, + pub next_eligible_micros: i64, + pub retry_candidate: Option, + pub failures: Vec, + pub deferred_destinations: Vec, + pub movement_progress: Option, + pub combat_progress: Option, + pub cast_progress: Option, + pub quest_progress: Vec, + pub history: Vec, + pub last_stall_check_micros: i64, + pub last_target_health: Option, + pub defense_target: Option, + pub transitions: u32, + pub route_expansions: u32, + pub route_budget: u32, +} + +crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_runner(ctx, character_guid) { + ctx.db.pkg_playerbots_runner().character_guid().delete(character_guid); +}); +crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_runner()); + +#[table(accessor = pkg_playerbots_scheduler, public)] +pub struct PlayerbotsScheduler { + #[primary_key] + pub id: u8, + pub observed_micros: i64, + pub processed: u32, + pub processed_guids: Vec, + pub excess_due: bool, + pub oldest_deferred_lag_micros: i64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScanStage { + Pending, + Complete, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecoveryResult { + Pending, + Missing, + Rotation(u64), +} + +enum RecoveryLookup { + Pending, + Missing, + Spell(PlayerbotsRotation), +} + +/// Each pass scans at most RECOVERY_SCAN_LIMIT indexed rows and checks at most two retained rows. +/// Completed scans restart on the next pass; their result remains usable while rescanning. +/// Transfer restarts the scan against the destination's current rotations and spellbook. +#[table(accessor = pkg_playerbots_recovery_scan, public)] +pub struct PlayerbotsRecoveryScan { + #[primary_key] + pub character_guid: u64, + pub class: u8, + pub role: u8, + pub stage: ScanStage, + pub after_rotation_id: u64, + pub best_rotation_id: Option, + pub result: RecoveryResult, + pub rows_scanned: u32, +} + +crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_recovery_scan(ctx, character_guid) { + ctx.db.pkg_playerbots_recovery_scan().character_guid().delete(character_guid); +}); +crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_recovery_scan()); + +fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { + let scans = ctx.db.pkg_playerbots_recovery_scan(); + let existing = scans.character_guid().find(bot.character_guid); + let replacing = existing.is_some(); + let mut scan = existing + .filter(|s| (s.class, s.role) == (bot.class, bot.role)) + .unwrap_or(PlayerbotsRecoveryScan { + character_guid: bot.character_guid, + class: bot.class, + role: bot.role, + stage: ScanStage::Pending, + after_rotation_id: 0, + best_rotation_id: None, + result: RecoveryResult::Pending, + rows_scanned: 0, + }); + let rotations = ctx.db.pkg_playerbots_rotation(); + let valid = |row: &PlayerbotsRotation| { + (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) + }; + if scan.stage == ScanStage::Complete { + scan.after_rotation_id = 0; + scan.best_rotation_id = None; + } + let mut best = scan + .best_rotation_id + .and_then(|id| rotations.id().find(id)) + .filter(&valid); + let rows: Vec<_> = rotations + .by_recovery_scan() + .filter(( + bot.class, + bot.role, + super::cond::ALLY_HP_BELOW_PCT, + ( + std::ops::Bound::Excluded(scan.after_rotation_id), + std::ops::Bound::Unbounded, + ), + )) + .take(RECOVERY_SCAN_LIMIT) + .collect(); + scan.rows_scanned = rows.len() as u32; + scan.stage = if rows.len() < RECOVERY_SCAN_LIMIT { + ScanStage::Complete + } else { + ScanStage::Pending + }; + for row in rows { + scan.after_rotation_id = row.id; + if valid(&row) + && best.as_ref().is_none_or(|b| { + (row.priority, row.spell_id, row.id) < (b.priority, b.spell_id, b.id) + }) + { + best = Some(row); + } + } + scan.best_rotation_id = best.as_ref().map(|r| r.id); + if scan.stage == ScanStage::Complete { + scan.result = scan + .best_rotation_id + .map_or(RecoveryResult::Missing, RecoveryResult::Rotation); + } + let lookup = match scan.result { + RecoveryResult::Pending => RecoveryLookup::Pending, + RecoveryResult::Missing => RecoveryLookup::Missing, + RecoveryResult::Rotation(id) => match rotations.id().find(id).filter(valid) { + Some(row) => RecoveryLookup::Spell(row), + None => { + scan.result = RecoveryResult::Missing; + RecoveryLookup::Missing + } + }, + }; + if replacing { + scans.character_guid().update(scan); + } else { + scans.insert(scan); + } + lookup +} + +impl PlayerbotsRunner { + fn initial(guid: u64, now: i64) -> Self { + Self { + character_guid: guid, + generation: 0, + objective_sequence: 0, + objective: None, + foreground: None, + chosen: None, + candidate_order: vec![], + last_outcome: RunnerOutcome::Initial, + observed_micros: now, + progress_age_micros: None, + retry_count: 0, + next_eligible_micros: now, + retry_candidate: None, + failures: vec![], + deferred_destinations: vec![], + movement_progress: None, + combat_progress: None, + cast_progress: None, + quest_progress: vec![], + history: vec![], + last_stall_check_micros: now, + last_target_health: None, + defense_target: None, + transitions: 0, + route_expansions: 0, + route_budget: 0, + } + } + + fn failure(&mut self, reason: Failure, now: i64) { + self.retry_count = self.retry_count.saturating_add(1).min(3); + bounded_push( + &mut self.failures, + FailureRecord { + reason, + at_micros: now, + }, + FAILURE_LIMIT, + ); + self.last_outcome = RunnerOutcome::Refused(reason); + } + + fn save(mut self, ctx: &ReducerContext) { + self.observed_micros = ctx.timestamp.to_micros_since_unix_epoch(); + self.next_eligible_micros = self + .next_eligible_micros + .max(self.observed_micros.saturating_add(INTERVAL)); + self.progress_age_micros = self.objective.as_ref().map(|o| { + self.observed_micros + .saturating_sub(o.last_verified_progress_micros.unwrap_or(o.started_micros)) + }); + let changed = self.history.last().is_none_or(|t| { + t.chosen != self.chosen + || std::mem::discriminant(&t.outcome) != std::mem::discriminant(&self.last_outcome) + }); + if changed { + let transition = Transition { + at_micros: self.observed_micros, + chosen: self.chosen, + outcome: self.last_outcome.clone(), + }; + bounded_push(&mut self.history, transition, HISTORY_LIMIT); + } + let rows = ctx.db.pkg_playerbots_runner(); + if rows.character_guid().find(self.character_guid).is_some() { + rows.character_guid().update(self); + } else { + rows.insert(self); + } + } +} + +fn bounded_push(values: &mut Vec, value: T, limit: usize) { + if values.len() >= limit { + values.remove(0); + } + values.push(value); +} + +pub(super) fn legacy_controls(ctx: &ReducerContext, guid: u64) -> bool { + ctx.db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .is_some_and(|bot| bot.controller == Controller::Legacy) + && crate::actor::sessionless_action_gate(ctx, guid).is_ok() +} + +pub(super) fn pass(ctx: &ReducerContext) { + super::ensure_defaults(ctx); + let now = ctx.timestamp.to_micros_since_unix_epoch(); + let bots = ctx.db.pkg_playerbots_bot(); + let due: Vec<_> = bots.by_due().filter(..=now).take(BATCH_LIMIT).collect(); + let guids = due.iter().map(|b| b.character_guid).collect(); + let count = due.len(); + for mut bot in due { + let mut state = ctx + .db + .pkg_playerbots_runner() + .character_guid() + .find(bot.character_guid) + .unwrap_or_else(|| PlayerbotsRunner::initial(bot.character_guid, now)); + if crate::actor::sessionless_action_gate(ctx, bot.character_guid).is_err() { + if matches!(bot.controller, Controller::Legacy | Controller::Cohort) { + stop(ctx, bot.character_guid, &mut state); + } + state.chosen = Some(Candidate { + id: decision::CandidateId { + action: Action::Hold, + reason: Reason::Restricted, + objective: state.objective_sequence, + }, + priority: 1000, + }); + state.last_outcome = RunnerOutcome::Waiting; + state.save(ctx); + } else { + match bot.controller { + Controller::Legacy => { + state.save(ctx); + super::goals::think(ctx, &bot, now); + } + Controller::RecordOnly | Controller::Cohort => run(ctx, &bot, state, now), + Controller::Frozen => { + state.save(ctx); + } + } + } + bot.scheduler_lag_micros = now.saturating_sub(bot.next_think_micros).max(0); + bot.next_think_micros = now.saturating_add(INTERVAL); + bots.id().update(bot); + } + let deferred = bots.by_due().filter(..=now).next(); + let row = PlayerbotsScheduler { + id: 0, + observed_micros: now, + processed: count as u32, + processed_guids: guids, + excess_due: deferred.is_some(), + oldest_deferred_lag_micros: deferred + .map_or(0, |bot| now.saturating_sub(bot.next_think_micros).max(0)), + }; + let rows = ctx.db.pkg_playerbots_scheduler(); + if rows.id().find(0).is_some() { + rows.id().update(row); + } else { + rows.insert(row); + } +} + +/// Selection is idempotent. A changed controller cancels work before the new generation can act. +#[reducer] +pub fn playerbots_select_controller( + ctx: &ReducerContext, + guid: u64, + controller: Controller, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + crate::actor::set_sessionless_action_consent( + ctx, + guid, + matches!(controller, Controller::Legacy | Controller::Cohort), + ); + if bot.controller == controller { + return Ok(()); + } + let now = ctx.timestamp.to_micros_since_unix_epoch(); + let mut state = ctx + .db + .pkg_playerbots_runner() + .character_guid() + .find(guid) + .unwrap_or_else(|| PlayerbotsRunner::initial(guid, now)); + stop(ctx, guid, &mut state); + state.last_stall_check_micros = now; + state.generation = state + .generation + .checked_add(1) + .ok_or("controller generation exhausted")?; + state.next_eligible_micros = if controller == Controller::Frozen { + i64::MAX + } else { + now + }; + state.last_outcome = if controller == Controller::Frozen { + RunnerOutcome::Frozen + } else { + RunnerOutcome::Cancelled + }; + state.save(ctx); + bot.controller = controller; + bot.next_think_micros = if controller == Controller::Frozen { + i64::MAX + } else { + now + }; + ctx.db.pkg_playerbots_bot().id().update(bot); + Ok(()) +} + +fn stop(ctx: &ReducerContext, guid: u64, state: &mut PlayerbotsRunner) { + if let Some(foreground) = state.foreground.take() { + bounded_push( + &mut state.history, + Transition { + at_micros: ctx.timestamp.to_micros_since_unix_epoch(), + chosen: Some(foreground.candidate), + outcome: RunnerOutcome::Cancelled, + }, + HISTORY_LIMIT, + ); + } + if let Some(cast) = crate::spell::pending_cast(ctx, guid) { + if actions::observation(ctx, guid, actions::ActionKind::Cast) + .is_some_and(|owned| owned.cast_id == cast.scheduled_id) + { + crate::spell::cancel_cast_attempt(ctx, guid, cast.scheduled_id); + } + } + if crate::actor::sessionless_action_gate(ctx, guid).is_ok() { + let _ = crate::actor::stop_attack(ctx, guid); + } + stop_movement(ctx, guid); +} + +fn stop_movement(ctx: &ReducerContext, guid: u64) { + let Some(spline) = ctx.db.game_creature_spline().guid().find(guid) else { + return; + }; + if !actions::observation(ctx, guid, actions::ActionKind::Move) + .is_some_and(|owned| owned.observed_micros as u64 == spline.start_micros) + { + return; + } + if let Some(me) = ctx.db.game_world_entity().guid().find(guid) { + let point = (me.x, me.y, me.z); + crate::creatures::tick::emit_move_spline( + ctx, + guid, + point, + point, + 0, + false, + (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32, + me.map_id, + me.instance_id, + (me.grid_x, me.grid_y), + ); + } else { + ctx.db.game_creature_spline().guid().delete(guid); + } +} + +fn geometry_revision(ctx: &ReducerContext, map: u32) -> Option { + crate::nav::coverage_generation(ctx, map) +} + +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), + }; + state.deferred_destinations.retain(|d| d.until_micros > now); + if state + .objective + .as_ref() + .is_none_or(|o| o.destination != destination) + { + state.objective_sequence = state.objective_sequence.saturating_add(1); + state.objective = Some(Objective { + identity: state.objective_sequence, + kind: ObjectiveKind::ReturnHome, + destination, + stage: ObjectiveStage::Travelling, + deadline_micros: now.saturating_add(OBJECTIVE_LIFETIME), + last_verified_progress_micros: None, + started_micros: now, + catalog_revision: CATALOG_REVISION, + }); + state.retry_count = 0; + state.last_stall_check_micros = now; + } + if let Some(o) = &mut state.objective { + if o.stage == ObjectiveStage::Deferred + && !state + .deferred_destinations + .iter() + .any(|d| d.destination == o.destination) + { + o.stage = ObjectiveStage::Travelling; + o.deadline_micros = now.saturating_add(OBJECTIVE_LIFETIME); + state.retry_count = 0; + state.last_stall_check_micros = now; + } + if o.stage == ObjectiveStage::Deferred { + if let Some(deferred) = state + .deferred_destinations + .iter() + .find(|d| d.destination == o.destination) + { + state.next_eligible_micros = state.next_eligible_micros.max(deferred.until_micros); + } + } + } +} + +fn defer(state: &mut PlayerbotsRunner, now: i64) { + if let Some(o) = &mut state.objective { + o.stage = ObjectiveStage::Deferred; + state + .deferred_destinations + .retain(|d| d.destination != o.destination); + bounded_push( + &mut state.deferred_destinations, + DeferredDestination { + destination: o.destination.clone(), + until_micros: now.saturating_add(DEFER_INTERVAL), + }, + FAILURE_LIMIT, + ); + } + state.next_eligible_micros = now.saturating_add(DEFER_INTERVAL); +} + +fn observe(ctx: &ReducerContext, me: &crate::WorldEntity, state: &mut PlayerbotsRunner, now: i64) { + if let Some(fg) = &state.foreground { + if fg.generation != state.generation + || (fg.map_id, fg.instance_id) != (me.map_id, me.instance_id) + { + stop(ctx, me.guid, state); + state.last_outcome = RunnerOutcome::Cancelled; + } + } + if let Some(fg) = &mut state.foreground { + match &mut fg.running { + Running::Movement(MovementRun { + destination, + from_x, + from_y, + }) => { + let advanced = (me.x - *from_x).powi(2) + (me.y - *from_y).powi(2) > 0.05 * 0.05; + let arrived = distance(me, destination) <= 2.05; + if advanced || arrived { + state.last_stall_check_micros = now; + state.retry_count = 0; + state.movement_progress = Some(MovementProgress { + observed_micros: now, + x: me.x, + y: me.y, + arrived, + }); + if let Some(o) = &mut state.objective { + if o.destination == *destination { + o.last_verified_progress_micros = Some(now); + state.retry_count = 0; + state.last_stall_check_micros = now; + if arrived { + o.stage = ObjectiveStage::Completed; + state.last_outcome = RunnerOutcome::Arrived; + } + } + } + } + *from_x = me.x; + *from_y = me.y; + if arrived { + state.foreground = None; + } + } + Running::Cast(handle) => { + if let Some(current) = crate::spell::pending_cast(ctx, me.guid) + .filter(|h| h.scheduled_id == handle.scheduled_id) + { + // Direct damage moves due_micros without changing the core cast identity. + *handle = current; + } else { + state.foreground = None; + state.failure(Failure::CastLost, now); + } + } + } + } + if let Some(previous) = &mut state.last_target_health { + if let Some(target) = ctx.db.game_world_entity().guid().find(previous.target) { + if target.health < previous.health { + state.combat_progress = Some(CombatProgress { + observed_micros: now, + target: target.guid, + health: target.health, + }); + } + previous.health = target.health; + } + } + for quest in ctx + .db + .game_character_quest() + .by_character() + .filter(me.guid) + .take(20) + { + let credit = quest.counts.iter().map(|&c| u64::from(c)).sum(); + if let Some(previous) = state + .quest_progress + .iter_mut() + .find(|q| q.quest == quest.quest_entry) + { + if credit > previous.credit || (quest.rewarded && !previous.rewarded) { + previous.observed_micros = now; + } + previous.credit = credit; + previous.rewarded = quest.rewarded; + } else { + bounded_push( + &mut state.quest_progress, + QuestProgress { + observed_micros: 0, + quest: quest.quest_entry, + credit, + rewarded: quest.rewarded, + }, + 20, + ); + } + } +} + +fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, now: i64) { + let Some(me) = ctx.db.game_world_entity().guid().find(bot.character_guid) else { + state.chosen = Some(Candidate { + id: decision::CandidateId { + action: Action::Hold, + reason: Reason::Restricted, + objective: state.objective_sequence, + }, + priority: 1000, + }); + state.last_outcome = RunnerOutcome::Waiting; + state.save(ctx); + return; + }; + let prior_objective = state.objective_sequence; + objective(ctx, bot, &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 { + 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; + if bot.controller == Controller::Cohort { + if let Some(o) = &mut state.objective { + if at_home { + o.stage = ObjectiveStage::Completed; + } else if o.stage == ObjectiveStage::Completed { + o.stage = ObjectiveStage::Travelling; + o.deadline_micros = now.saturating_add(OBJECTIVE_LIFETIME); + state.last_stall_check_micros = now; + } + } + } + let personality = ctx + .db + .pkg_playerbots_personality() + .by_character() + .filter(me.guid) + .next(); + let flee_at = personality.as_ref().map_or(15, |p| p.flee_at_pct); + let low_health = super::goals::should_flee(me.health, me.max_health, flee_at); + let threat = state + .defense_target + .and_then(|guid| ctx.db.game_world_entity().guid().find(guid)) + .filter(|t| !t.dead && (t.map_id, t.instance_id) == (me.map_id, me.instance_id)); + state.defense_target = threat.as_ref().map(|target| target.guid); + let recovery_lookup = recovery_spell(ctx, bot); + let spell = match &recovery_lookup { + RecoveryLookup::Spell(row) => Some(row), + RecoveryLookup::Pending | RecoveryLookup::Missing => None, + }; + let restricted = me.dead || 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, + }; + 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 { + Readiness::Refused + } else if state.next_eligible_micros > now { + Readiness::NotBefore(state.next_eligible_micros) + } else { + Readiness::Ready + }; + home_action + .alternatives + .push(node(Action::Hold, Reason::ReturnHome, 100)); + let mut recovery = node( + spell.as_ref().map_or(Action::Hold, |spell| { + Action::Cast(CastAction { + target: me.guid, + spell: spell.spell_id, + }) + }), + Reason::Recovery, + 800, + ); + if matches!(recovery_lookup, RecoveryLookup::Missing) { + recovery.readiness = Readiness::Refused; + } + let mut defense = node( + threat + .as_ref() + .map_or(Action::Hold, |target| Action::Attack(target.guid)), + Reason::Defense, + 600, + ); + if threat.is_none() { + defense.readiness = Readiness::Refused; + } + if let Some(target) = &threat { + let mut close = node( + Action::Move(MoveTarget::Entity(target.guid)), + Reason::Defense, + 600, + ); + if ((target.x - me.x).powi(2) + (target.y - me.y).powi(2)).sqrt() <= 4.0 { + close.readiness = Readiness::Complete; + } + defense.prerequisites.push(close); + } + defense.continuers.push(home_action.clone()); + let mut survival = if partition_ok && !at_home { + node(Action::Move(MoveTarget::Home), Reason::Survival, 900) + } else { + node(Action::Hold, Reason::Survival, 900) + }; + if let Some(deferred) = state + .deferred_destinations + .iter() + .find(|d| d.destination == home) + { + 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)| { + if state.retry_candidate == Some(n.candidate.id) && state.next_eligible_micros > now { + n.readiness = Readiness::NotBefore(state.next_eligible_micros); + } + Strategy { + trigger, + candidates: vec![n], + defaults: vec![], + priority_adjustment: 0, + } + }) + .collect(); + let decision = decision::choose(&facts, &strategies, decision::LIMITS); + state.candidate_order = decision.order; + state.transitions = decision.transitions as u32; + state.route_expansions = 0; + state.route_budget = decision.route_expansions; + let chosen = decision.chosen; + if bot.controller == Controller::RecordOnly { + state.chosen = chosen; + state.last_outcome = RunnerOutcome::Recorded; + state.save(ctx); + return; + } + if let Some(deadline) = state + .objective + .as_ref() + .filter(|o| o.stage == ObjectiveStage::Travelling && now >= o.deadline_micros) + .map(|o| o.deadline_micros) + { + if let Some(foreground) = state.foreground.clone() { + if let Running::Cast(handle) = foreground.running { + if crate::spell::expire_cast_attempt(ctx, me.guid, handle.scheduled_id, deadline) { + state.foreground = None; + bounded_push( + &mut state.history, + Transition { + at_micros: now, + chosen: Some(foreground.candidate), + outcome: RunnerOutcome::CastFinished(crate::spell::CastFinish::Expired), + }, + HISTORY_LIMIT, + ); + } + } + } + stop(ctx, me.guid, &mut state); + state.failure(Failure::Deadline, now); + defer(&mut state, now); + state.save(ctx); + return; + } + 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); + if incompatible && preempts { + stop(ctx, me.guid, &mut state); + state.last_outcome = RunnerOutcome::Cancelled; + } else if matches!(fg.running, Running::Cast(_)) { + state.chosen = Some(fg.candidate); + state.last_outcome = RunnerOutcome::Waiting; + state.save(ctx); + return; + } else if incompatible && !preempts { + // A completed leg releases ownership; a running leg retains it until observed arrival. + if ctx + .db + .game_creature_spline() + .guid() + .find(me.guid) + .is_some_and(|s| { + s.start_micros.saturating_add(u64::from(s.dur_ms) * 1000) > now.max(0) as u64 + }) + { + state.chosen = Some(fg.candidate); + state.last_outcome = RunnerOutcome::Waiting; + state.save(ctx); + return; + } + state.foreground = None; + } + } + state.chosen = chosen; + if let Some(candidate) = chosen { + execute(ctx, &me, &home, candidate, &mut state, now); + } else if let Some(reason) = decision.refusals.first() { + state.failure(Failure::Decision(*reason), now); + } + state.next_eligible_micros = state.next_eligible_micros.max(now.saturating_add(INTERVAL)); + state.save(ctx); +} + +fn execute( + ctx: &ReducerContext, + me: &crate::WorldEntity, + home: &Destination, + candidate: Candidate, + state: &mut PlayerbotsRunner, + now: i64, +) { + match candidate.id.action { + Action::Hold => { + if candidate.id.reason == Reason::Restricted || candidate.id.reason == Reason::Survival + { + stop(ctx, me.guid, state); + } + if candidate.id.reason == Reason::ReturnHome + && (home.map_id, home.instance_id) != (me.map_id, me.instance_id) + && state + .objective + .as_ref() + .is_some_and(|o| o.stage != ObjectiveStage::Deferred) + { + state.failure(Failure::DestinationUnavailable, now); + defer(state, now); + return; + } + state.last_outcome = if state + .objective + .as_ref() + .is_some_and(|o| o.stage == ObjectiveStage::Completed) + { + RunnerOutcome::Arrived + } else { + RunnerOutcome::Waiting + }; + } + 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), + }) + } + }; + 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); + return; + }; + if candidate.id.reason == Reason::Survival { + let _ = crate::actor::stop_attack(ctx, me.guid); + } + if now.saturating_sub(state.last_stall_check_micros) >= STALL_INTERVAL { + state.failure(Failure::NoMovement, now); + state.last_stall_check_micros = now; + if state.retry_count >= 3 { + stop(ctx, me.guid, state); + defer(state, now); + return; + } + } + super::goals::walk_toward( + ctx, + me, + (dest.x, dest.y, dest.z), + if target == MoveTarget::Home { 2.0 } else { 3.0 }, + true, + ); + state.route_expansions = + super::actions::observation(ctx, me.guid, super::actions::ActionKind::Move) + .and_then(|row| match row.outcome { + super::actions::ActionOutcome::Movement(observation) => { + Some(observation.route.expansions) + } + _ => None, + }) + .unwrap_or(0); + state.foreground = Some(Foreground { + candidate, + generation: state.generation, + map_id: me.map_id, + instance_id: me.instance_id, + started_micros: now, + running: Running::Movement(MovementRun { + destination: dest, + from_x: me.x, + from_y: me.y, + }), + }); + state.last_outcome = RunnerOutcome::Waiting; + } + Action::Cast(CastAction { target, spell }) => { + stop_movement(ctx, me.guid); + let _ = crate::actor::stop_attack(ctx, me.guid); + match super::actions::cast(ctx, me.guid, spell, target) { + Ok( + crate::spell::CastStart::Started(handle) + | crate::spell::CastStart::Waiting(handle), + ) => { + let mut actual = candidate; + actual.id.action = Action::Cast(CastAction { + spell: handle.spell_id, + target: handle.target_guid, + }); + state.foreground = Some(Foreground { + candidate: actual, + generation: state.generation, + map_id: me.map_id, + instance_id: me.instance_id, + started_micros: now, + running: Running::Cast(handle), + }); + state.last_outcome = RunnerOutcome::Waiting; + } + Ok(crate::spell::CastStart::Resolved) => { + state.cast_progress = Some(CastProgress { + observed_micros: now, + scheduled_id: 0, + spell, + target, + }); + state.last_outcome = + RunnerOutcome::CastFinished(crate::spell::CastFinish::Resolved); + state.retry_candidate = None; + } + Err(reason) => { + state.failure(Failure::CastRefused(reason.kind), now); + state.retry_candidate = Some(candidate.id); + state.next_eligible_micros = now.saturating_add(if state.retry_count >= 3 { + DEFER_INTERVAL + } else { + INTERVAL * i64::from(state.retry_count) + }); + } + } + } + Action::Attack(target) => { + if let Some(target) = ctx.db.game_world_entity().guid().find(target) { + state.last_target_health = Some(CombatProgress { + observed_micros: now, + target: target.guid, + health: target.health, + }); + } + match super::actions::attack(ctx, me.guid, target) { + Ok(_) => state.last_outcome = RunnerOutcome::Accepted, + Err(reason) => state.failure(Failure::ActionRefused(reason.kind), now), + } + } + } +} + +crate::game_hook!(on_cast_finished, fn playerbots_runner_cast_finished(ctx, payload) { + let Some(bot) = ctx.db.pkg_playerbots_bot().by_character().filter(payload.caster_guid).next() else { return; }; + if bot.controller != Controller::Cohort { return; } + let Some(mut state) = ctx.db.pkg_playerbots_runner().character_guid().find(payload.caster_guid) else { return; }; + let Some(fg) = &state.foreground else { return; }; + let Running::Cast(handle) = &fg.running else { return; }; + if fg.generation != state.generation || handle.scheduled_id != payload.scheduled_id { return; } + if !ctx.db.game_world_entity().guid().find(payload.caster_guid).is_some_and(|e| (e.map_id, e.instance_id) == (fg.map_id, fg.instance_id)) { return; } + let now = ctx.timestamp.to_micros_since_unix_epoch(); + if matches!(payload.outcome, crate::spell::CastFinish::Resolved) { + state.cast_progress = Some(CastProgress { observed_micros: now, scheduled_id: handle.scheduled_id, spell: handle.spell_id, target: handle.target_guid }); + state.retry_candidate = None; + } + state.foreground = None; + state.last_stall_check_micros = now; + state.last_outcome = RunnerOutcome::CastFinished(payload.outcome.clone()); + state.save(ctx); +}); + +crate::game_hook!(on_damage_taken, fn playerbots_runner_reconsider_damage(ctx, payload) { + if payload.attacker_guid == 0 { return; } + let Some(mut bot) = ctx.db.pkg_playerbots_bot().by_character().filter(payload.target_guid).next() else { return; }; + if !matches!(bot.controller, Controller::Cohort | Controller::RecordOnly) { return; } + let now = ctx.timestamp.to_micros_since_unix_epoch(); + let mut state = ctx.db.pkg_playerbots_runner().character_guid().find(payload.target_guid) + .unwrap_or_else(|| PlayerbotsRunner::initial(payload.target_guid, now)); + state.defense_target = Some(payload.attacker_guid); + state.save(ctx); + bot.next_think_micros = bot.next_think_micros.min(now); + ctx.db.pkg_playerbots_bot().id().update(bot); +}); + +// Both active controllers use this one invite response. Sharded admission belongs to the Gateway. +crate::game_hook!(on_group_invite, fn playerbots_auto_accept(ctx, payload) { + let Some(bot) = ctx.db.pkg_playerbots_bot().by_character().filter(payload.target_guid).next() else { return; }; + if !matches!(bot.controller, Controller::Legacy | Controller::Cohort) + || crate::actor::sessionless_action_gate(ctx, payload.target_guid).is_err() + { + return; + } + let inviter = crate::helpers::character_by_guid(ctx, payload.inviter_guid) + .map(|character| character.name) + .unwrap_or_else(|| payload.inviter_guid.to_string()); + match crate::actor::accept_group_invite(ctx, payload.target_guid) { + Ok(()) => spacetimedb::log::info!( + "playerbots: bot {} accepted a party invite from {inviter}", + payload.target_guid + ), + Err(refusal) => spacetimedb::log::warn!( + "playerbots: bot {} could not accept the invite from {inviter}: {refusal}", + payload.target_guid + ), + } +});