From 23fb42e784d8399f09f205999a243f85b5392617 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:19:23 +0000 Subject: [PATCH 01/17] feat(playerbots): retain an objective through bounded decisions --- .github/workflows/core-tip.yml | 26 + playerbots/README.md | 66 ++ playerbots/src/actions.rs | 12 +- playerbots/src/decision.rs | 520 +++++++++++++++ playerbots/src/fixture.rs | 160 +++++ playerbots/src/goals.rs | 79 +-- playerbots/src/mod.rs | 15 +- playerbots/src/runner.rs | 1105 ++++++++++++++++++++++++++++++++ 8 files changed, 1904 insertions(+), 79 deletions(-) create mode 100644 playerbots/src/decision.rs create mode 100644 playerbots/src/runner.rs diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index cb93caa..70caeb0 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -146,6 +146,32 @@ 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 + env: + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + run: | + export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/lyracore/target" + cargo clean --manifest-path "$GITHUB_WORKSPACE/preceding-core/Cargo.toml" -p lyracore-module + ./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..ae53c0a 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -397,3 +397,69 @@ 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. + +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; candidate identity includes its +action, target, spell, trigger and objective identity. There is no string registry. + +The migration appends `controller = Legacy` and `scheduler_lag_micros = 0` to the roster and adds +runner 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 row. 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..95b51e0 --- /dev/null +++ b/playerbots/src/decision.rs @@ -0,0 +1,520 @@ +//! Pure, bounded selection from typed Strategies and explicit facts. + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Action { + Hold, + Move, + Cast, + Attack, +} + +#[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 target: u64, + pub event: Reason, + pub spell: u32, + 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, target: u64, event: Reason, priority: i32) -> Self { + Self { + candidate: Candidate { + id: CandidateId { + action, + target, + event, + spell: 0, + 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: 4096, +}; + +#[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, +} + +impl Search { + fn refuse(&mut self, reason: DecisionRefusal) { + 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.refusals.len(); + pending = self.resolve(prerequisite, now, path); + if pending.is_none() + && prerequisite.readiness == Readiness::Complete + && self.refusals.len() == 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 node.candidate.id.action == Action::Move + && self.limits.route_expansions < 4096 + { + self.refuse(DecisionRefusal::RouteWork); + self.first(&node.alternatives, now, path) + } else { + if node.candidate.id.action == Action::Move { + self.route_expansions = 4096; + } + 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(), + }; + 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.target).collect::>(), + vec![11, 22] + ); + assert_eq!(result.chosen.unwrap().id.target, 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, 0, Reason::Survival, 100)], + defaults: vec![ActionNode::ready(Action::Hold, 0, 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 + ); + f.low_health = true; + assert_eq!( + choose(&f, &strategies, LIMITS).chosen.unwrap().id.event, + Reason::Survival + ); + } + + #[test] + fn prerequisites_keep_the_target_and_continuers_require_completion() { + let mut node = attack(22); + node.prerequisites + .push(ActionNode::ready(Action::Move, 22, Reason::Defense, 1)); + let result = choose(&facts(), &[strategy(vec![node.clone()])], LIMITS); + assert_eq!( + result.chosen.unwrap(), + Candidate { + id: CandidateId { + action: Action::Move, + target: 22, + event: Reason::Defense, + spell: 0, + objective: 0 + }, + priority: 50 + } + ); + node.prerequisites[0].readiness = Readiness::Complete; + node.continuers + .push(ActionNode::ready(Action::Move, 0, Reason::ReturnHome, 10)); + assert_eq!( + choose(&facts(), &[strategy(vec![node.clone()])], LIMITS) + .chosen + .unwrap() + .id + .action, + Action::Attack + ); + node.readiness = Readiness::Complete; + assert_eq!( + choose(&facts(), &[strategy(vec![node])], LIMITS) + .chosen + .unwrap() + .id + .event, + Reason::ReturnHome + ); + } + + #[test] + fn cycles_and_each_work_limit_refuse_boundedly() { + let mut cycle = attack(11); + cycle.prerequisites.push(attack(11)); + assert!(choose(&facts(), &[strategy(vec![cycle])], LIMITS) + .refusals + .contains(&DecisionRefusal::Cycle)); + 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, + 0, + 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, 0, 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.target, 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..c5a6060 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -535,3 +535,163 @@ 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); + playerbots_fixture_runner_due(ctx) +} + +#[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 - 1..=cx + 1 { + for y in cy - 1..=cy + 1 { + 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); + playerbots_fixture_runner_due(ctx) +} 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..a4c8ed9 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 @@ -694,7 +702,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..02df0c6 --- /dev/null +++ b/playerbots/src/runner.rs @@ -0,0 +1,1105 @@ +//! One controller, one retained objective, and one foreground action per bot. + +use super::actions; +use super::decision::{ + self, Action, ActionNode, Candidate, DecisionRefusal, Readiness, Reason, Strategy, Trigger, +}; +use super::{ + pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, +}; +use crate::{ + game_character, game_character_quest, game_creature_spline, game_player_spell, game_threat, + 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 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, +} + +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, + target: 0, + event: Reason::Restricted, + spell: 0, + 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.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, + target: 0, + event: Reason::Restricted, + spell: 0, + 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 observed_attacker = 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 = observed_attacker.as_ref().map(|target| target.guid); + let threat = observed_attacker.or_else(|| { + ctx.db + .game_threat() + .by_source() + .filter(me.guid) + .take(24) + .filter_map(|t| ctx.db.game_world_entity().guid().find(t.creature_guid)) + .filter(|t| !t.dead && (t.map_id, t.instance_id) == (me.map_id, me.instance_id)) + .min_by_key(|t| t.guid) + }); + let spell = ctx + .db + .pkg_playerbots_rotation() + .by_class_role() + .filter((bot.class, bot.role)) + .take(24) + .filter(|r| r.condition == super::cond::ALLY_HP_BELOW_PCT) + .filter(|r| { + ctx.db + .game_player_spell() + .by_character() + .filter(me.guid) + .take(256) + .any(|s| s.spell_id == r.spell_id) + }) + .min_by_key(|r| (r.priority, r.spell_id)); + let restricted = me.dead || crate::helpers::live_entity(ctx, me.guid).is_err(); + let facts = decision::Facts { + now, + restricted, + low_health, + wounded: me.health.saturating_mul(100) < me.max_health.saturating_mul(50), + attacked: threat.is_some(), + away: !at_home, + }; + let node = |action, target, reason, priority| { + let mut node = ActionNode::ready(action, target, reason, priority); + node.candidate.id.objective = state.objective_sequence; + node + }; + let mut home_action = node(Action::Move, 0, 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, 0, Reason::ReturnHome, 100)); + let mut recovery = node(Action::Cast, me.guid, Reason::Recovery, 800); + if let Some(spell) = spell { + recovery.candidate.id.spell = spell.spell_id; + } else { + recovery.readiness = Readiness::Refused; + } + let mut defense = node( + Action::Attack, + threat.as_ref().map_or(0, |t| t.guid), + Reason::Defense, + 600, + ); + if let Some(target) = &threat { + let mut close = node(Action::Move, 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 survival = if partition_ok && !at_home { + node(Action::Move, 0, Reason::Survival, 900) + } else { + node(Action::Hold, 0, Reason::Survival, 900) + }; + let strategies: Vec<_> = [ + ( + Trigger::Restricted, + node(Action::Hold, 0, Reason::Restricted, 1000), + ), + (Trigger::LowHealth, survival), + (Trigger::Wounded, recovery), + (Trigger::Attacked, defense), + (Trigger::Away, home_action), + (Trigger::Always, node(Action::Hold, 0, 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.event == Reason::Restricted || candidate.id.event == Reason::Survival { + stop(ctx, me.guid, state); + } + if candidate.id.event == 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 => { + let destination = if candidate.id.target == 0 { + Some(home.clone()) + } else { + ctx.db + .game_world_entity() + .guid() + .find(candidate.id.target) + .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.event == 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 candidate.id.target == 0 { 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 => { + stop_movement(ctx, me.guid); + let _ = crate::actor::stop_attack(ctx, me.guid); + match super::actions::cast(ctx, me.guid, candidate.id.spell, candidate.id.target) { + Ok( + crate::spell::CastStart::Started(handle) + | crate::spell::CastStart::Waiting(handle), + ) => { + let mut actual = candidate; + actual.id.spell = handle.spell_id; + actual.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: candidate.id.spell, + target: candidate.id.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 => { + if let Some(target) = ctx.db.game_world_entity().guid().find(candidate.id.target) { + state.last_target_health = Some(CombatProgress { + observed_micros: now, + target: target.guid, + health: target.health, + }); + } + match super::actions::attack(ctx, me.guid, candidate.id.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 = 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 + ), + } +}); From 46bd480d7d91e558d837979d9a922df04676861b Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:26:21 +0000 Subject: [PATCH 02/17] fix(playerbots): retain deferrals and count refused prerequisites --- playerbots/src/decision.rs | 23 +++++++++++++++++++++-- playerbots/src/runner.rs | 14 +++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index 95b51e0..c549148 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -149,10 +149,12 @@ struct Search { 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); } @@ -191,11 +193,11 @@ impl Search { let mut pending = None; let mut refused = false; for prerequisite in &node.prerequisites { - let refusals_before = self.refusals.len(); + let refusals_before = self.refusal_count; pending = self.resolve(prerequisite, now, path); if pending.is_none() && prerequisite.readiness == Readiness::Complete - && self.refusals.len() == refusals_before + && self.refusal_count == refusals_before { continue; } @@ -283,6 +285,7 @@ pub fn choose(facts: &Facts, strategies: &[Strategy], limits: Limits) -> Decisio transitions: 0, route_expansions: 0, refusals: Vec::new(), + refusal_count: 0, }; if overflow { search.refuse(DecisionRefusal::Candidates); @@ -416,6 +419,22 @@ mod tests { ); } + #[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_and_each_work_limit_refuse_boundedly() { let mut cycle = attack(11); diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 02df0c6..a6015cc 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -571,6 +571,8 @@ fn observe(ctx: &ReducerContext, me: &crate::WorldEntity, state: &mut Playerbots 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, @@ -777,11 +779,21 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n defense.prerequisites.push(close); } defense.continuers.push(home_action.clone()); - let survival = if partition_ok && !at_home { + let mut survival = if partition_ok && !at_home { node(Action::Move, 0, Reason::Survival, 900) } else { node(Action::Hold, 0, 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, 0, Reason::Survival, 900)); + } let strategies: Vec<_> = [ ( Trigger::Restricted, From cfe503c90dc8199bbfd9c087c7b88737612c5ff4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:27:07 +0000 Subject: [PATCH 03/17] ci(playerbots): check the paired Core runner contract --- .github/workflows/core-tip.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 70caeb0..32ff5e0 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -37,11 +37,11 @@ jobs: with: path: collection - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: 7114fe00329c231174b125522494500473f1006e path: lyracore - name: Install Rust 1.93.0 @@ -61,11 +61,11 @@ jobs: with: path: collection - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: 7114fe00329c231174b125522494500473f1006e path: lyracore # `spacetime generate` builds the module to wasm and extracts its schema through it, so this @@ -120,11 +120,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: 7114fe00329c231174b125522494500473f1006e path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target From 19dc90ac5f1ebd6b5f6786dd211ec8052bd20f49 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:28:20 +0000 Subject: [PATCH 04/17] fix(playerbots): fence the inviter name read --- playerbots/src/runner.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index a6015cc..895ed17 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -8,8 +8,7 @@ use super::{ pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, }; use crate::{ - game_character, game_character_quest, game_creature_spline, game_player_spell, game_threat, - game_world_entity, + game_character_quest, game_creature_spline, game_player_spell, game_threat, game_world_entity, }; use spacetimedb::{reducer, table, ReducerContext, Table}; @@ -1097,11 +1096,7 @@ crate::game_hook!(on_group_invite, fn playerbots_auto_accept(ctx, payload) { { return; } - let inviter = ctx - .db - .game_character() - .guid() - .find(payload.inviter_guid) + 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) { From 8871191b7a2c6244dfc0bdc13868afea944b377f Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:29:59 +0000 Subject: [PATCH 05/17] ci(playerbots): build preceding Wasm with its Cargo configuration --- .github/workflows/core-tip.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 32ff5e0..ad6e88c 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -41,7 +41,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 7114fe00329c231174b125522494500473f1006e + ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 path: lyracore - name: Install Rust 1.93.0 @@ -65,7 +65,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 7114fe00329c231174b125522494500473f1006e + ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 path: lyracore # `spacetime generate` builds the module to wasm and extracts its schema through it, so this @@ -124,7 +124,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 7114fe00329c231174b125522494500473f1006e + ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target @@ -161,13 +161,14 @@ jobs: 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/lyracore/target" cargo clean --manifest-path "$GITHUB_WORKSPACE/preceding-core/Cargo.toml" -p lyracore-module - ./preceding-collection/.github/check-core-tip.sh "$GITHUB_WORKSPACE/preceding-core" \ + "$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" From e3c414586763b0f9cf7ae592d3747ec599c24acc Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:34:00 +0000 Subject: [PATCH 06/17] refactor(playerbots): carry required fields in typed actions --- playerbots/README.md | 5 +- playerbots/src/decision.rs | 94 +++++++++++++++++------------ playerbots/src/runner.rs | 120 +++++++++++++++++++++---------------- 3 files changed, 127 insertions(+), 92 deletions(-) diff --git a/playerbots/README.md b/playerbots/README.md index ae53c0a..42e6fac 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -447,8 +447,9 @@ coverage generation. A coverage change invalidates the prior destination decisio 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; candidate identity includes its -action, target, spell, trigger and objective identity. There is no string registry. +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 and scheduler tables. Existing goals and action observations keep their schema and meaning. diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index c549148..7ef610d 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -1,11 +1,19 @@ //! 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, - Cast, - Attack, + Move(MoveTarget), + Cast { target: u64, spell: u32 }, + Attack { target: u64 }, +} + +#[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)] @@ -21,9 +29,7 @@ pub enum Reason { #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct CandidateId { pub action: Action, - pub target: u64, - pub event: Reason, - pub spell: u32, + pub reason: Reason, pub objective: u64, } @@ -51,14 +57,12 @@ pub struct ActionNode { } impl ActionNode { - pub fn ready(action: Action, target: u64, event: Reason, priority: i32) -> Self { + pub fn ready(action: Action, reason: Reason, priority: i32) -> Self { Self { candidate: Candidate { id: CandidateId { action, - target, - event, - spell: 0, + reason, objective: 0, }, priority, @@ -122,7 +126,7 @@ pub const LIMITS: Limits = Limits { candidates: 24, depth: 4, transitions: 16, - route_expansions: 4096, + route_expansions: LEG_MAX_EXPANSIONS, }; #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] @@ -209,14 +213,14 @@ impl Search { } else if let Some(mut candidate) = pending { candidate.priority = candidate.priority.max(node.candidate.priority); Some(candidate) - } else if node.candidate.id.action == Action::Move - && self.limits.route_expansions < 4096 + } 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 node.candidate.id.action == Action::Move { - self.route_expansions = 4096; + if matches!(node.candidate.id.action, Action::Move(_)) { + self.route_expansions = LEG_MAX_EXPANSIONS; } Some(node.candidate) } @@ -336,7 +340,7 @@ mod tests { } } fn attack(target: u64) -> ActionNode { - ActionNode::ready(Action::Attack, target, Reason::Defense, 50) + ActionNode::ready(Action::Attack { target }, Reason::Defense, 50) } #[test] @@ -348,10 +352,13 @@ mod tests { LIMITS, ); assert_eq!( - result.order.iter().map(|c| c.id.target).collect::>(), - vec![11, 22] + result.order.iter().map(|c| c.id.action).collect::>(), + vec![Action::Attack { target: 11 }, Action::Attack { target: 22 }] + ); + assert_eq!( + result.chosen.unwrap().id.action, + Action::Attack { target: 11 } ); - assert_eq!(result.chosen.unwrap().id.target, 11); } } @@ -361,19 +368,23 @@ mod tests { defense.priority_adjustment = 40; let survival = Strategy { trigger: Trigger::LowHealth, - candidates: vec![ActionNode::ready(Action::Move, 0, Reason::Survival, 100)], - defaults: vec![ActionNode::ready(Action::Hold, 0, Reason::Idle, 1)], + 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 + Action::Attack { target: 11 } ); f.low_health = true; assert_eq!( - choose(&f, &strategies, LIMITS).chosen.unwrap().id.event, + choose(&f, &strategies, LIMITS).chosen.unwrap().id.reason, Reason::Survival ); } @@ -381,32 +392,36 @@ mod tests { #[test] fn prerequisites_keep_the_target_and_continuers_require_completion() { let mut node = attack(22); - node.prerequisites - .push(ActionNode::ready(Action::Move, 22, Reason::Defense, 1)); + 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, - target: 22, - event: Reason::Defense, - spell: 0, + 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, 0, Reason::ReturnHome, 10)); + 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 + Action::Attack { target: 22 } ); node.readiness = Readiness::Complete; assert_eq!( @@ -414,7 +429,7 @@ mod tests { .chosen .unwrap() .id - .event, + .reason, Reason::ReturnHome ); } @@ -436,12 +451,16 @@ mod tests { } #[test] - fn cycles_and_each_work_limit_refuse_boundedly() { + 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 { @@ -469,8 +488,7 @@ mod tests { let result = choose( &facts(), &[strategy(vec![ActionNode::ready( - Action::Move, - 0, + Action::Move(MoveTarget::Home), Reason::ReturnHome, 1, )])], @@ -485,7 +503,7 @@ mod tests { 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, 0, Reason::Idle, 1); + let mut fallback = ActionNode::ready(Action::Hold, Reason::Idle, 1); fallback.readiness = Readiness::NotBefore(101); node.alternatives.push(fallback); let strategies = [strategy(vec![node])]; @@ -512,7 +530,7 @@ mod tests { ); assert_eq!(result.order.len(), 2); assert_eq!(result.order[0].priority, 100); - assert_eq!(result.order[0].id.target, 11); + assert_eq!(result.order[0].id.action, Action::Attack { target: 11 }); } #[test] diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 895ed17..ffca951 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -2,7 +2,8 @@ use super::actions; use super::decision::{ - self, Action, ActionNode, Candidate, DecisionRefusal, Readiness, Reason, Strategy, Trigger, + self, Action, ActionNode, Candidate, DecisionRefusal, MoveTarget, Readiness, Reason, Strategy, + Trigger, }; use super::{ pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, @@ -319,9 +320,7 @@ pub(super) fn pass(ctx: &ReducerContext) { state.chosen = Some(Candidate { id: decision::CandidateId { action: Action::Hold, - target: 0, - event: Reason::Restricted, - spell: 0, + reason: Reason::Restricted, objective: state.objective_sequence, }, priority: 1000, @@ -659,9 +658,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n state.chosen = Some(Candidate { id: decision::CandidateId { action: Action::Hold, - target: 0, - event: Reason::Restricted, - spell: 0, + reason: Reason::Restricted, objective: state.objective_sequence, }, priority: 1000, @@ -742,12 +739,12 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n attacked: threat.is_some(), away: !at_home, }; - let node = |action, target, reason, priority| { - let mut node = ActionNode::ready(action, target, reason, priority); + 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, 0, Reason::ReturnHome, 100); + 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 { @@ -757,21 +754,36 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n }; home_action .alternatives - .push(node(Action::Hold, 0, Reason::ReturnHome, 100)); - let mut recovery = node(Action::Cast, me.guid, Reason::Recovery, 800); - if let Some(spell) = spell { - recovery.candidate.id.spell = spell.spell_id; - } else { + .push(node(Action::Hold, Reason::ReturnHome, 100)); + let mut recovery = node( + spell.as_ref().map_or(Action::Hold, |spell| Action::Cast { + target: me.guid, + spell: spell.spell_id, + }), + Reason::Recovery, + 800, + ); + if spell.is_none() { recovery.readiness = Readiness::Refused; } let mut defense = node( - Action::Attack, - threat.as_ref().map_or(0, |t| t.guid), + threat + .as_ref() + .map_or(Action::Hold, |target| Action::Attack { + target: target.guid, + }), Reason::Defense, 600, ); + if threat.is_none() { + defense.readiness = Readiness::Refused; + } if let Some(target) = &threat { - let mut close = node(Action::Move, target.guid, Reason::Defense, 600); + 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; } @@ -779,9 +791,9 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n } defense.continuers.push(home_action.clone()); let mut survival = if partition_ok && !at_home { - node(Action::Move, 0, Reason::Survival, 900) + node(Action::Move(MoveTarget::Home), Reason::Survival, 900) } else { - node(Action::Hold, 0, Reason::Survival, 900) + node(Action::Hold, Reason::Survival, 900) }; if let Some(deferred) = state .deferred_destinations @@ -791,18 +803,18 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n survival.readiness = Readiness::NotBefore(deferred.until_micros); survival .alternatives - .push(node(Action::Hold, 0, Reason::Survival, 900)); + .push(node(Action::Hold, Reason::Survival, 900)); } let strategies: Vec<_> = [ ( Trigger::Restricted, - node(Action::Hold, 0, Reason::Restricted, 1000), + node(Action::Hold, Reason::Restricted, 1000), ), (Trigger::LowHealth, survival), (Trigger::Wounded, recovery), (Trigger::Attacked, defense), (Trigger::Away, home_action), - (Trigger::Always, node(Action::Hold, 0, Reason::Idle, 0)), + (Trigger::Always, node(Action::Hold, Reason::Idle, 0)), ] .into_iter() .map(|(trigger, mut n)| { @@ -907,10 +919,11 @@ fn execute( ) { match candidate.id.action { Action::Hold => { - if candidate.id.event == Reason::Restricted || candidate.id.event == Reason::Survival { + if candidate.id.reason == Reason::Restricted || candidate.id.reason == Reason::Survival + { stop(ctx, me.guid, state); } - if candidate.id.event == Reason::ReturnHome + if candidate.id.reason == Reason::ReturnHome && (home.map_id, home.instance_id) != (me.map_id, me.instance_id) && state .objective @@ -931,22 +944,23 @@ fn execute( RunnerOutcome::Waiting }; } - Action::Move => { - let destination = if candidate.id.target == 0 { - Some(home.clone()) - } else { - ctx.db - .game_world_entity() - .guid() - .find(candidate.id.target) - .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), - }) + 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)) @@ -955,7 +969,7 @@ fn execute( defer(state, now); return; }; - if candidate.id.event == Reason::Survival { + 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 { @@ -971,7 +985,7 @@ fn execute( ctx, me, (dest.x, dest.y, dest.z), - if candidate.id.target == 0 { 2.0 } else { 3.0 }, + if target == MoveTarget::Home { 2.0 } else { 3.0 }, true, ); state.route_expansions = @@ -997,17 +1011,19 @@ fn execute( }); state.last_outcome = RunnerOutcome::Waiting; } - Action::Cast => { + Action::Cast { target, spell } => { stop_movement(ctx, me.guid); let _ = crate::actor::stop_attack(ctx, me.guid); - match super::actions::cast(ctx, me.guid, candidate.id.spell, candidate.id.target) { + 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.spell = handle.spell_id; - actual.id.target = handle.target_guid; + actual.id.action = Action::Cast { + spell: handle.spell_id, + target: handle.target_guid, + }; state.foreground = Some(Foreground { candidate: actual, generation: state.generation, @@ -1022,8 +1038,8 @@ fn execute( state.cast_progress = Some(CastProgress { observed_micros: now, scheduled_id: 0, - spell: candidate.id.spell, - target: candidate.id.target, + spell, + target, }); state.last_outcome = RunnerOutcome::CastFinished(crate::spell::CastFinish::Resolved); @@ -1040,15 +1056,15 @@ fn execute( } } } - Action::Attack => { - if let Some(target) = ctx.db.game_world_entity().guid().find(candidate.id.target) { + 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, candidate.id.target) { + match super::actions::attack(ctx, me.guid, target) { Ok(_) => state.last_outcome = RunnerOutcome::Accepted, Err(reason) => state.failure(Failure::ActionRefused(reason.kind), now), } From 2a7aeca404f3dcfa455a447e3216f4772f1feccb Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:34:37 +0000 Subject: [PATCH 07/17] ci(playerbots): pair the typed action route budget --- .github/workflows/core-tip.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index ad6e88c..b73b443 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -41,7 +41,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 + ref: 41488bfe8fecae21498371e172969c01948e7bc2 path: lyracore - name: Install Rust 1.93.0 @@ -65,7 +65,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 + ref: 41488bfe8fecae21498371e172969c01948e7bc2 path: lyracore # `spacetime generate` builds the module to wasm and extracts its schema through it, so this @@ -124,7 +124,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 916181024cbdf85bc2f798b31658ef2df9380bf0 + ref: 41488bfe8fecae21498371e172969c01948e7bc2 path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target From 584d01b00b2b241b6d5667bb810b49af0430d634 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:39:51 +0000 Subject: [PATCH 08/17] fix(playerbots): encode typed actions as SpacetimeDB payload variants --- playerbots/src/decision.rs | 25 ++++++++++++++----------- playerbots/src/runner.rs | 24 ++++++++++++------------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index 7ef610d..6d8a85e 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -6,8 +6,14 @@ use crate::nav::LEG_MAX_EXPANSIONS; pub enum Action { Hold, Move(MoveTarget), - Cast { target: u64, spell: u32 }, - Attack { target: u64 }, + 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)] @@ -340,7 +346,7 @@ mod tests { } } fn attack(target: u64) -> ActionNode { - ActionNode::ready(Action::Attack { target }, Reason::Defense, 50) + ActionNode::ready(Action::Attack(target), Reason::Defense, 50) } #[test] @@ -353,12 +359,9 @@ mod tests { ); assert_eq!( result.order.iter().map(|c| c.id.action).collect::>(), - vec![Action::Attack { target: 11 }, Action::Attack { target: 22 }] - ); - assert_eq!( - result.chosen.unwrap().id.action, - Action::Attack { target: 11 } + vec![Action::Attack(11), Action::Attack(22)] ); + assert_eq!(result.chosen.unwrap().id.action, Action::Attack(11)); } } @@ -380,7 +383,7 @@ mod tests { let strategies = [defense, survival]; assert_eq!( choose(&f, &strategies, LIMITS).chosen.unwrap().id.action, - Action::Attack { target: 11 } + Action::Attack(11) ); f.low_health = true; assert_eq!( @@ -421,7 +424,7 @@ mod tests { .unwrap() .id .action, - Action::Attack { target: 22 } + Action::Attack(22) ); node.readiness = Readiness::Complete; assert_eq!( @@ -530,7 +533,7 @@ mod tests { ); assert_eq!(result.order.len(), 2); assert_eq!(result.order[0].priority, 100); - assert_eq!(result.order[0].id.action, Action::Attack { target: 11 }); + assert_eq!(result.order[0].id.action, Action::Attack(11)); } #[test] diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index ffca951..2d53b74 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -2,8 +2,8 @@ use super::actions; use super::decision::{ - self, Action, ActionNode, Candidate, DecisionRefusal, MoveTarget, Readiness, Reason, Strategy, - Trigger, + self, Action, ActionNode, Candidate, CastAction, DecisionRefusal, MoveTarget, Readiness, + Reason, Strategy, Trigger, }; use super::{ pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, @@ -756,9 +756,11 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n .alternatives .push(node(Action::Hold, Reason::ReturnHome, 100)); let mut recovery = node( - spell.as_ref().map_or(Action::Hold, |spell| Action::Cast { - target: me.guid, - spell: spell.spell_id, + spell.as_ref().map_or(Action::Hold, |spell| { + Action::Cast(CastAction { + target: me.guid, + spell: spell.spell_id, + }) }), Reason::Recovery, 800, @@ -769,9 +771,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n let mut defense = node( threat .as_ref() - .map_or(Action::Hold, |target| Action::Attack { - target: target.guid, - }), + .map_or(Action::Hold, |target| Action::Attack(target.guid)), Reason::Defense, 600, ); @@ -1011,7 +1011,7 @@ fn execute( }); state.last_outcome = RunnerOutcome::Waiting; } - Action::Cast { target, spell } => { + 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) { @@ -1020,10 +1020,10 @@ fn execute( | crate::spell::CastStart::Waiting(handle), ) => { let mut actual = candidate; - actual.id.action = Action::Cast { + actual.id.action = Action::Cast(CastAction { spell: handle.spell_id, target: handle.target_guid, - }; + }); state.foreground = Some(Foreground { candidate: actual, generation: state.generation, @@ -1056,7 +1056,7 @@ fn execute( } } } - Action::Attack { target } => { + Action::Attack(target) => { if let Some(target) = ctx.db.game_world_entity().guid().find(target) { state.last_target_health = Some(CombatProgress { observed_micros: now, From 334ea463845903e60d3823ee2af707cbaa49723d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 14:52:02 +0000 Subject: [PATCH 09/17] ci(playerbots): isolate the preceding Wasm build artifacts --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index b73b443..9286622 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -166,7 +166,7 @@ jobs: CARGO_BUILD_JOBS: 2 CARGO_INCREMENTAL: 0 run: | - export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/lyracore/target" + 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" \ From 1a6577308e7327a3c33f1d7074748d0a93771367 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:06:48 +0000 Subject: [PATCH 10/17] ci(playerbots): validate the runner against merged Core main --- .github/workflows/core-tip.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 9286622..1e0971a 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -37,11 +37,11 @@ jobs: with: path: collection - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 41488bfe8fecae21498371e172969c01948e7bc2 + ref: main path: lyracore - name: Install Rust 1.93.0 @@ -61,11 +61,11 @@ jobs: with: path: collection - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 41488bfe8fecae21498371e172969c01948e7bc2 + ref: main path: lyracore # `spacetime generate` builds the module to wasm and extracts its schema through it, so this @@ -120,11 +120,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: 41488bfe8fecae21498371e172969c01948e7bc2 + ref: main path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target From 2a352124d6fa5ea364807581d2d3321718524dfd Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:24:02 +0000 Subject: [PATCH 11/17] fix(playerbots): finish bounded recovery scans without false refusals --- .github/workflows/core-tip.yml | 6 ++ playerbots/README.md | 8 ++ playerbots/src/fixture.rs | 82 ++++++++++++++++-- playerbots/src/mod.rs | 3 +- playerbots/src/runner.rs | 147 +++++++++++++++++++++++++-------- 5 files changed, 205 insertions(+), 41 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 1e0971a..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 diff --git a/playerbots/README.md b/playerbots/README.md index 42e6fac..09089dc 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -464,3 +464,11 @@ build Module Wasm from core `be3fa67d0f0c24749230560544a3e8e8b577f61d` with coll 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. + +Recovery examines at most 24 matching healing rotation rows per pass. The separate +`pkg_playerbots_recovery_scan` explanation records its indexed cursor, examined row count, +and Pending or Complete stage. A first incomplete scan selects Recovery Hold. 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. diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index c5a6060..7568ba8 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 { @@ -651,7 +652,7 @@ pub fn playerbots_fixture_runner_survival(ctx: &ReducerContext, guid: u64) -> Re .ok_or("personality missing")?; personality.flee_at_pct = 100; ctx.db.pkg_playerbots_personality().id().update(personality); - playerbots_fixture_runner_due(ctx) + runner_due_for(ctx, guid) } #[reducer] @@ -663,8 +664,8 @@ pub fn playerbots_fixture_runner_clear_navigation( 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 - 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) { ctx.db .game_nav_chunk() .key() @@ -693,5 +694,72 @@ pub fn playerbots_fixture_runner_expire_objective( .pkg_playerbots_runner() .character_guid() .update(state); - playerbots_fixture_runner_due(ctx) + 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.clone() + }); + } + rotations.insert(super::PlayerbotsRotation { id: 0, ..heal }); + let mut me = crate::helpers::live_entity(ctx, guid)?; + me.health = 1_000_000_000; + me.max_health = 4_000_000_000; + ctx.db.game_world_entity().guid().update(me); + Ok(()) } diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index a4c8ed9..3005c27 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -307,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] diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 2d53b74..abf3ed7 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -7,10 +7,9 @@ use super::decision::{ }; use super::{ pkg_playerbots_bot, pkg_playerbots_personality, pkg_playerbots_rotation, PlayerbotsBot, + PlayerbotsRotation, }; -use crate::{ - game_character_quest, game_creature_spline, game_player_spell, game_threat, game_world_entity, -}; +use crate::{game_character_quest, game_creature_spline, game_world_entity}; use spacetimedb::{reducer, table, ReducerContext, Table}; pub const BATCH_LIMIT: usize = 16; @@ -19,6 +18,7 @@ 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; @@ -207,6 +207,112 @@ pub struct PlayerbotsScheduler { pub oldest_deferred_lag_micros: i64, } +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScanStage { + Pending, + Complete, +} + +/// Each pass reads at most RECOVERY_SCAN_LIMIT healing rows. A completed scan starts again on +/// the next pass; a retained result is checked against current rotation and spellbook rows. +#[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_id: u64, + pub best_id: Option, + pub selected_id: Option, + pub rows_examined: 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, +) -> (Option, ScanStage) { + let scans = ctx.db.pkg_playerbots_recovery_scan(); + let existing = scans.character_guid().find(bot.character_guid); + let mut scan = existing + .clone() + .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_id: 0, + best_id: None, + selected_id: None, + rows_examined: 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_id = 0; + scan.best_id = None; + } + let mut best = scan + .best_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_id), + std::ops::Bound::Unbounded, + ), + )) + .take(RECOVERY_SCAN_LIMIT) + .collect(); + scan.rows_examined = rows.len() as u32; + scan.stage = if rows.len() < RECOVERY_SCAN_LIMIT { + ScanStage::Complete + } else { + ScanStage::Pending + }; + for row in rows { + scan.after_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_id = best.as_ref().map(|r| r.id); + if scan.stage == ScanStage::Complete { + scan.selected_id = scan.best_id; + } + let selected = scan + .selected_id + .and_then(|id| rotations.id().find(id)) + .filter(valid); + scan.selected_id = selected.as_ref().map(|r| r.id); + let stage = scan.stage; + if existing.is_some() { + scans.character_guid().update(scan); + } else { + scans.insert(scan); + } + (selected, stage) +} + impl PlayerbotsRunner { fn initial(guid: u64, now: i64) -> Self { Self { @@ -699,43 +805,18 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n .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 observed_attacker = state + 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 = observed_attacker.as_ref().map(|target| target.guid); - let threat = observed_attacker.or_else(|| { - ctx.db - .game_threat() - .by_source() - .filter(me.guid) - .take(24) - .filter_map(|t| ctx.db.game_world_entity().guid().find(t.creature_guid)) - .filter(|t| !t.dead && (t.map_id, t.instance_id) == (me.map_id, me.instance_id)) - .min_by_key(|t| t.guid) - }); - let spell = ctx - .db - .pkg_playerbots_rotation() - .by_class_role() - .filter((bot.class, bot.role)) - .take(24) - .filter(|r| r.condition == super::cond::ALLY_HP_BELOW_PCT) - .filter(|r| { - ctx.db - .game_player_spell() - .by_character() - .filter(me.guid) - .take(256) - .any(|s| s.spell_id == r.spell_id) - }) - .min_by_key(|r| (r.priority, r.spell_id)); + state.defense_target = threat.as_ref().map(|target| target.guid); + let (spell, recovery_stage) = recovery_spell(ctx, bot); let restricted = me.dead || crate::helpers::live_entity(ctx, me.guid).is_err(); let facts = decision::Facts { now, restricted, low_health, - wounded: me.health.saturating_mul(100) < me.max_health.saturating_mul(50), + wounded: u64::from(me.health) * 100 < u64::from(me.max_health) * 50, attacked: threat.is_some(), away: !at_home, }; @@ -765,7 +846,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n Reason::Recovery, 800, ); - if spell.is_none() { + if spell.is_none() && recovery_stage == ScanStage::Complete { recovery.readiness = Readiness::Refused; } let mut defense = node( From 363bfa6edf564c702569e5df75f29911aaeb9623 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:26:01 +0000 Subject: [PATCH 12/17] docs(playerbots): describe recovery scan migration and cleanup --- playerbots/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/playerbots/README.md b/playerbots/README.md index 09089dc..1200b18 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -426,6 +426,14 @@ while healing itself with a known supported rotation spell, defending against da 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 examines at most 24 matching healing rotation rows per pass. The separate +`pkg_playerbots_recovery_scan` explanation records its indexed cursor, examined row count, +and Pending or Complete stage. A first incomplete scan selects Recovery Hold. 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 @@ -452,9 +460,9 @@ payload, reason and objective identity. Movement names Home or an Entity; Cast c 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 and scheduler tables. Existing goals and action observations keep their schema and meaning. +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 row. Production publication still requires the +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, @@ -464,11 +472,3 @@ build Module Wasm from core `be3fa67d0f0c24749230560544a3e8e8b577f61d` with coll 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. - -Recovery examines at most 24 matching healing rotation rows per pass. The separate -`pkg_playerbots_recovery_scan` explanation records its indexed cursor, examined row count, -and Pending or Complete stage. A first incomplete scan selects Recovery Hold. 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. From d2af850f1ef2bda5c5b9122fa1d328bf4b2ac55a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:29:00 +0000 Subject: [PATCH 13/17] fix(playerbots): retain completed recovery results while rescanning --- playerbots/README.md | 8 +++-- playerbots/src/runner.rs | 63 ++++++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/playerbots/README.md b/playerbots/README.md index 1200b18..1bd8a21 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -426,9 +426,11 @@ while healing itself with a known supported rotation spell, defending against da 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 examines at most 24 matching healing rotation rows per pass. The separate -`pkg_playerbots_recovery_scan` explanation records its indexed cursor, examined row count, -and Pending or Complete stage. A first incomplete scan selects Recovery Hold. Completed +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 diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index abf3ed7..e5ef990 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -213,8 +213,21 @@ pub enum ScanStage { Complete, } -/// Each pass reads at most RECOVERY_SCAN_LIMIT healing rows. A completed scan starts again on -/// the next pass; a retained result is checked against current rotation and spellbook rows. +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecoveryResult { + Pending, + Missing, + Spell(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. #[table(accessor = pkg_playerbots_recovery_scan, public)] pub struct PlayerbotsRecoveryScan { #[primary_key] @@ -224,8 +237,8 @@ pub struct PlayerbotsRecoveryScan { pub stage: ScanStage, pub after_id: u64, pub best_id: Option, - pub selected_id: Option, - pub rows_examined: u32, + pub result: RecoveryResult, + pub rows_scanned: u32, } crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_recovery_scan(ctx, character_guid) { @@ -233,10 +246,7 @@ crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_recovery_scan(ctx }); crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_recovery_scan()); -fn recovery_spell( - ctx: &ReducerContext, - bot: &PlayerbotsBot, -) -> (Option, ScanStage) { +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 mut scan = existing @@ -249,8 +259,8 @@ fn recovery_spell( stage: ScanStage::Pending, after_id: 0, best_id: None, - selected_id: None, - rows_examined: 0, + result: RecoveryResult::Pending, + rows_scanned: 0, }); let rotations = ctx.db.pkg_playerbots_rotation(); let valid = |row: &PlayerbotsRotation| { @@ -279,7 +289,7 @@ fn recovery_spell( )) .take(RECOVERY_SCAN_LIMIT) .collect(); - scan.rows_examined = rows.len() as u32; + scan.rows_scanned = rows.len() as u32; scan.stage = if rows.len() < RECOVERY_SCAN_LIMIT { ScanStage::Complete } else { @@ -297,20 +307,27 @@ fn recovery_spell( } scan.best_id = best.as_ref().map(|r| r.id); if scan.stage == ScanStage::Complete { - scan.selected_id = scan.best_id; + scan.result = scan + .best_id + .map_or(RecoveryResult::Missing, RecoveryResult::Spell); } - let selected = scan - .selected_id - .and_then(|id| rotations.id().find(id)) - .filter(valid); - scan.selected_id = selected.as_ref().map(|r| r.id); - let stage = scan.stage; + let lookup = match scan.result { + RecoveryResult::Pending => RecoveryLookup::Pending, + RecoveryResult::Missing => RecoveryLookup::Missing, + RecoveryResult::Spell(id) => match rotations.id().find(id).filter(valid) { + Some(row) => RecoveryLookup::Spell(row), + None => { + scan.result = RecoveryResult::Missing; + RecoveryLookup::Missing + } + }, + }; if existing.is_some() { scans.character_guid().update(scan); } else { scans.insert(scan); } - (selected, stage) + lookup } impl PlayerbotsRunner { @@ -810,7 +827,11 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n .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 (spell, recovery_stage) = recovery_spell(ctx, bot); + 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, @@ -846,7 +867,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n Reason::Recovery, 800, ); - if spell.is_none() && recovery_stage == ScanStage::Complete { + if matches!(recovery_lookup, RecoveryLookup::Missing) { recovery.readiness = Readiness::Refused; } let mut defense = node( From a0263986cccb0bf78768d566e41e126a965de4d0 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:30:14 +0000 Subject: [PATCH 14/17] fix(playerbots): move lookup rows without requiring Clone --- playerbots/src/fixture.rs | 2 +- playerbots/src/runner.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 7568ba8..46119cd 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -753,7 +753,7 @@ pub fn playerbots_fixture_runner_wide_recovery( rotations.insert(super::PlayerbotsRotation { id: 0, spell_id, - ..heal.clone() + ..heal }); } rotations.insert(super::PlayerbotsRotation { id: 0, ..heal }); diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index e5ef990..7f99b37 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -249,8 +249,8 @@ crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_recove 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 - .clone() .filter(|s| (s.class, s.role) == (bot.class, bot.role)) .unwrap_or(PlayerbotsRecoveryScan { character_guid: bot.character_guid, @@ -322,7 +322,7 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { } }, }; - if existing.is_some() { + if replacing { scans.character_guid().update(scan); } else { scans.insert(scan); From 9e20aa66269c937cdd777a3cbf884837053c545e Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:34:18 +0000 Subject: [PATCH 15/17] refactor(playerbots): name recovery rotation identities explicitly --- playerbots/src/fixture.rs | 4 ---- playerbots/src/runner.rs | 29 +++++++++++++++-------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 46119cd..0090d0b 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -757,9 +757,5 @@ pub fn playerbots_fixture_runner_wide_recovery( }); } rotations.insert(super::PlayerbotsRotation { id: 0, ..heal }); - let mut me = crate::helpers::live_entity(ctx, guid)?; - me.health = 1_000_000_000; - me.max_health = 4_000_000_000; - ctx.db.game_world_entity().guid().update(me); Ok(()) } diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 7f99b37..9bf5770 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -217,7 +217,7 @@ pub enum ScanStage { pub enum RecoveryResult { Pending, Missing, - Spell(u64), + Rotation(u64), } enum RecoveryLookup { @@ -228,6 +228,7 @@ enum RecoveryLookup { /// 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] @@ -235,8 +236,8 @@ pub struct PlayerbotsRecoveryScan { pub class: u8, pub role: u8, pub stage: ScanStage, - pub after_id: u64, - pub best_id: Option, + pub after_rotation_id: u64, + pub best_rotation_id: Option, pub result: RecoveryResult, pub rows_scanned: u32, } @@ -257,8 +258,8 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { class: bot.class, role: bot.role, stage: ScanStage::Pending, - after_id: 0, - best_id: None, + after_rotation_id: 0, + best_rotation_id: None, result: RecoveryResult::Pending, rows_scanned: 0, }); @@ -269,11 +270,11 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { && crate::spell::knows_spell(ctx, bot.character_guid, row.spell_id) }; if scan.stage == ScanStage::Complete { - scan.after_id = 0; - scan.best_id = None; + scan.after_rotation_id = 0; + scan.best_rotation_id = None; } let mut best = scan - .best_id + .best_rotation_id .and_then(|id| rotations.id().find(id)) .filter(&valid); let rows: Vec<_> = rotations @@ -283,7 +284,7 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { bot.role, super::cond::ALLY_HP_BELOW_PCT, ( - std::ops::Bound::Excluded(scan.after_id), + std::ops::Bound::Excluded(scan.after_rotation_id), std::ops::Bound::Unbounded, ), )) @@ -296,7 +297,7 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { ScanStage::Pending }; for row in rows { - scan.after_id = row.id; + 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) @@ -305,16 +306,16 @@ fn recovery_spell(ctx: &ReducerContext, bot: &PlayerbotsBot) -> RecoveryLookup { best = Some(row); } } - scan.best_id = best.as_ref().map(|r| r.id); + scan.best_rotation_id = best.as_ref().map(|r| r.id); if scan.stage == ScanStage::Complete { scan.result = scan - .best_id - .map_or(RecoveryResult::Missing, RecoveryResult::Spell); + .best_rotation_id + .map_or(RecoveryResult::Missing, RecoveryResult::Rotation); } let lookup = match scan.result { RecoveryResult::Pending => RecoveryLookup::Pending, RecoveryResult::Missing => RecoveryLookup::Missing, - RecoveryResult::Spell(id) => match rotations.id().find(id).filter(valid) { + RecoveryResult::Rotation(id) => match rotations.id().find(id).filter(valid) { Some(row) => RecoveryLookup::Spell(row), None => { scan.result = RecoveryResult::Missing; From dba96bb1a5366d5f779f1b66820931583f426419 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:35:44 +0000 Subject: [PATCH 16/17] ci(playerbots): pair recovery tests with exact spellbook lookup --- .github/workflows/core-tip.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 4ba5522..2bb10c9 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -40,11 +40,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 path: lyracore persist-credentials: false @@ -66,11 +66,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 path: lyracore persist-credentials: false @@ -126,11 +126,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the paired LyraCore change uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target From 62c459770eb5b2209b7cfbf38e54857454fa69d2 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 16:21:29 +0000 Subject: [PATCH 17/17] ci(playerbots): verify recovery runner against merged core --- .github/workflows/core-tip.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 2bb10c9..4ba5522 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -40,11 +40,11 @@ jobs: path: collection persist-credentials: false - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 + ref: main path: lyracore persist-credentials: false @@ -66,11 +66,11 @@ jobs: path: collection persist-credentials: false - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 + ref: main path: lyracore persist-credentials: false @@ -126,11 +126,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out the paired LyraCore change + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: a4b551893897c647b2cc1dfc6d7f1f2e2b375081 + ref: main path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target