From 2564c7b4ce7799dadef63f8f5c9471ae58e49ad7 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:40:13 +0000 Subject: [PATCH 01/19] feat(playerbots): add gated provisioning operations --- docs/package-api.md | 9 + module/src/actor.rs | 16 + module/src/items/inventory.rs | 67 +++ module/src/items/ops.rs | 47 +- module/src/skill.rs | 90 +++- module/src/talent.rs | 121 ++++- module/src/trainer.rs | 119 ++++- module/tests/playerbots_provisioning.rs | 654 ++++++++++++++++++++++++ 8 files changed, 1095 insertions(+), 28 deletions(-) create mode 100644 module/tests/playerbots_provisioning.rs diff --git a/docs/package-api.md b/docs/package-api.md index b7ea7c180..45fc3d1a4 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -102,6 +102,15 @@ supported-lifecycle, range, line-of-sight, and ordinary cast Gates used at cast turn an `OutOfRange` or `NoLineOfSight` Refusal into a movement prerequisite without spending power, starting a cooldown, or creating a cast. +Provisioning uses five Actor verbs. `reconcile_profile_spell`, `reconcile_profile_skill`, and +`learn_profile_talent` return `Result`; `true` means they changed normal +Character state and `false` means the selected profile entry was already satisfied. +`reconcile_profile_item` returns the granted count and caps its target at 200. +`equip_profile_upgrade` returns whether it equipped the carried item and preserves equal or stronger +gear. These verbs do not authorize an Actor. A Package must first pass the same controller and +Sessionless Action Gate as any other gameplay request. Their grants have no money cost because the +selected profile is the source of the entitlement. + `actor::request_cast(ctx, actor_guid, spell_id, target_guid)` returns `Result`. `Started` carries a Cast Handle. `Waiting` carries an existing cast's original identity, spell, target, and current due time, even when the new request diff --git a/module/src/actor.rs b/module/src/actor.rs index 366630663..1baaa5b36 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -28,6 +28,11 @@ //! | `cast_item_target` | `creatures::apply_item_target_spell` | spell kind + owned carried item + effect-specific gates | //! | `equip_item` | `items::apply_equip_item` | slot type/level gates | //! | `trainer_buy` | `trainer::apply_trainer_buy` | trainer resolved by guid + offering gates (class/level/money) | +//! | `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback | +//! | `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates | +//! | `learn_profile_talent` | `talent::do_learn_talent` | class/race/level/point/tier/prerequisite gates | +//! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | +//! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | //! | `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates | //! | `repop` | `world::do_repop` | dead actor releases to the graveyard ghost | //! | `respond_resurrect` | `spell::do_resurrect_response` | consume the actor's pending rez offer; accept revives IN PLACE at the offer's % | @@ -124,6 +129,8 @@ package_only! { pub(crate) use crate::items::apply_item_use as use_item; pub(crate) use crate::loot::open_creature_corpse as open_creature_loot; pub(crate) use crate::items::apply_take_loot as take_loot; + pub(crate) use crate::items::request_profile_item as reconcile_profile_item; + pub(crate) use crate::items::apply_equip_profile_upgrade as equip_profile_upgrade; // `loot_money` also feeds playerbots' drink-at-rest behavior (work-item 154). pub(crate) use crate::loot::apply_loot_money as loot_money; } @@ -133,6 +140,11 @@ debug_only! { pub(crate) use crate::items::apply_equip_item as equip_item; pub(crate) use crate::items::apply_item_sell as sell_item; } +package_only! { + pub(crate) use crate::trainer::reconcile_profile_spell as reconcile_profile_spell; + pub(crate) use crate::skill::reconcile_profile_skill as reconcile_profile_skill; + pub(crate) use crate::talent::reconcile_profile_talent as learn_profile_talent; +} // ---- NPC services / world ---- @@ -173,6 +185,10 @@ pub enum ActionRefusalKind { OutOfRange, InventoryFull, Other, + MissingResource, + Class, + Level, + Prerequisite, } impl ActionRefusal { diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 057e49612..9faea2435 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -356,6 +356,73 @@ pub(crate) fn apply_equip_item( apply_item_move(ctx, player_guid, from_slot, to_slot) } +/// Equip a profile item only when its destination is empty or holds lower item-level gear. The +/// existing equip operation still owns every slot, level, proficiency, skill, reputation, and bind +/// Gate. Full bag slots and missing equipped templates preserve what the Character already wears. +pub(crate) fn apply_equip_profile_upgrade( + ctx: &ReducerContext, + player_guid: u64, + from_slot: u8, +) -> Result { + if from_slot <= BAG_SLOT_END_INCL { + return Err(refuse( + ItemRefusal::WrongSlot, + "profile equipment must come from carried storage", + )); + } + let candidate = item_in_slot(ctx, player_guid, from_slot).ok_or_else(|| { + refuse( + ItemRefusal::ItemNotFound, + format!("no item in slot {from_slot}"), + ) + })?; + let candidate_template = ctx + .db + .game_item_template() + .entry() + .find(candidate.entry) + .ok_or_else(|| { + refuse( + ItemRefusal::ItemNotFound, + format!("no template for item entry {}", candidate.entry), + ) + })?; + + let destination = if candidate_template.inventory_type == invtype::BAG { + let Some(slot) = first_free_bag_equip_slot(ctx, player_guid) else { + return Ok(false); + }; + slot + } else { + let can_dual_wield = crate::spell::knows_spell( + ctx, + player_guid, + lyracore_shared::constants::dual_wield::SPELL_ID, + ); + resolve_equip_slot(candidate_template.inventory_type, can_dual_wield, |slot| { + slot_occupied(ctx, player_guid, slot) + }) + .ok_or_else(|| { + refuse( + ItemRefusal::CannotEquip, + format!("item {} is not equippable", candidate.entry), + ) + })? + }; + + if let Some(equipped) = item_in_slot(ctx, player_guid, destination) { + let Some(equipped_template) = ctx.db.game_item_template().entry().find(equipped.entry) + else { + return Ok(false); + }; + if equipped_template.item_level >= candidate_template.item_level { + return Ok(false); + } + } + apply_equip_item(ctx, player_guid, from_slot)?; + Ok(true) +} + /// Shared UNEQUIP logic for the player + debug paths: take the item in equipment `from_slot` (0..=18) /// and move it to the first free backpack slot. Rejects a non-equipment source slot (it's not equipped) /// or a full backpack. Delegates to `apply_item_move` so the placement is the exact same move primitive; diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index d65d12668..10c52a126 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -184,14 +184,23 @@ pub(crate) fn request_grant_item( count: u32, property: Option, ) -> Result<(), ActionRefusal> { - let player = crate::helpers::live_entity(ctx, player_guid) - .map_err(|_| "player not in world".to_string())?; + let player = crate::helpers::live_entity(ctx, player_guid).map_err(|_| { + ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingActor, + "player not in world", + ) + })?; let tmpl = ctx .db .game_item_template() .entry() .find(item_entry) - .ok_or_else(|| format!("no such item {item_entry}"))?; + .ok_or_else(|| { + ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingResource, + format!("no such item {item_entry}"), + ) + })?; store_item_typed( ctx, player_guid, @@ -203,6 +212,38 @@ pub(crate) fn request_grant_item( ) } +/// Top a profile-owned carried resource up to a fixed target. The target is capped at the largest +/// supported ammunition stack so a malformed profile cannot turn periodic repair into an unlimited +/// grant. Storage still owns capacity, uniqueness, and Random Property Gates. +pub(crate) fn request_profile_item( + ctx: &ReducerContext, + player_guid: u64, + item_entry: u32, + target_count: u32, +) -> Result { + const PROFILE_ITEM_LIMIT: u32 = 200; + if target_count == 0 || target_count > PROFILE_ITEM_LIMIT { + return Err(ActionRefusal::new( + crate::actor::ActionRefusalKind::Other, + format!("profile item target {target_count} is outside 1..={PROFILE_ITEM_LIMIT}"), + )); + } + let owned = ctx + .db + .game_item_instance() + .by_owner_guid() + .filter(&player_guid) + .filter(|row| row.entry == item_entry) + .map(|row| row.stack_count) + .fold(0u32, u32::saturating_add); + let missing = target_count.saturating_sub(owned); + if missing == 0 { + return Ok(0); + } + request_grant_item(ctx, player_guid, item_entry, missing, None)?; + Ok(missing) +} + /// Add items to matching carried stacks, then free backpack or bag slots. /// Capacity, uniqueness and every Random Property are resolved before changing any item, so callers may /// handle an inventory-full Refusal without keeping a partial grant. diff --git a/module/src/skill.rs b/module/src/skill.rs index 495889419..5682a63d6 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -19,7 +19,7 @@ use lyracore_shared::constants::starter_item; // `game_player_skill` is defined in THIS module (the `#[table]` accessor is generated here, like // `threat::game_threat`), so it's in scope without a `use`. `game_item_template` (items/) is read to // resolve the equipped weapon's subclass; `game_world_entity` (world.rs) only by the debug reducer. -use crate::skilldata::game_skill_ability; +use crate::skilldata::{game_skill_ability, game_skill_availability}; use crate::{game_item_template, game_world_entity, WorldEntity}; // accessor trait for the autolearn read (282) // =========================================================================================== @@ -601,6 +601,94 @@ pub(crate) fn raise_combat_caps(ctx: &ReducerContext, guid: u64, new_level: u32) } } +fn profile_mask_admits(mask: u32, id: u8) -> bool { + mask == 0 || (id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0) +} + +/// Reconcile one combat skill selected by a free provisioning profile. Imported availability owns +/// the race, class, and level Gates. A seed-only Shard falls back to the established class weapon +/// table so the no-import demo remains usable. +pub(crate) fn reconcile_profile_skill( + ctx: &ReducerContext, + guid: u64, + line: u32, +) -> Result { + if !is_combat_skill_line(line) { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Class, + format!("skill line {line} is not a combat skill"), + )); + } + let actor = crate::helpers::live_entity(ctx, guid).map_err(|_| { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingActor, + format!("learner {guid} not in world"), + ) + })?; + let availability: Vec<_> = ctx + .db + .game_skill_availability() + .by_skill_line() + .filter(&line) + .collect(); + let imported = ctx.db.game_skill_availability().count() > 0; + let identity_admitted = availability.iter().any(|row| { + profile_mask_admits(row.race_mask, actor.race()) + && profile_mask_admits(row.class_mask, actor.class()) + }); + let admitted = if imported { + availability.iter().any(|row| { + profile_mask_admits(row.race_mask, actor.race()) + && profile_mask_admits(row.class_mask, actor.class()) + && i64::from(row.min_level) <= i64::from(actor.level) + }) + } else { + line == skill_line::DEFENSE + || line == skill_line::UNARMED + || class_weapon_skill_lines(actor.class()).contains(&line) + }; + if !admitted { + let kind = if imported && identity_admitted { + crate::actor::ActionRefusalKind::Level + } else { + crate::actor::ActionRefusalKind::Class + }; + return Err(crate::actor::ActionRefusal::new( + kind, + format!( + "skill line {line} is not available to race {} class {} at level {}", + actor.race(), + actor.class(), + actor.level + ), + )); + } + + let cap = skill_cap_for_level(actor.level) as u16; + let skills = ctx.db.game_player_skill(); + if let Some(mut row) = skills + .by_character() + .filter(&guid) + .find(|row| row.skill_line == line) + { + if row.max_rank >= cap { + return Ok(false); + } + row.max_rank = cap; + skills.id().update(row); + } else { + skills.insert(PlayerSkill { + id: 0, + character_guid: guid, + owner_identity: actor.owner_identity, + skill_line: line, + current: 1, + max_rank: cap, + }); + } + Ok(true) +} + /// Skill-up a PROFESSION line (e.g. Cooking) after a successful craft/gather: ROLL the orange/gray /// difficulty band (`skillup_chance_bp`) and, on success, climb one step toward the row's own `max_rank` /// via `raise_skill` (caps on the row's stored cap — 75/150/…, NOT `level*5`, so a profession climbs diff --git a/module/src/talent.rs b/module/src/talent.rs index 80382f8bd..efddca9da 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -20,18 +20,14 @@ //! (re-derived correctly at the next learn); gossip surfacing of "unlearn talents" at the trainer //! (work-item 198 follow-up — the reducer is wired, the gateway arm is not). [entity] //! -//! DEMO SEED (work-item 207): `seed_talents` inserts a small hand-authored Warrior-flavored tree (ids -//! 1-8) so a no-DBC sandbox has SOMETHING to learn/test against. KEPT rather than retired even after the -//! real `TalentTab.dbc`/`Talent.dbc` importer (`importer/src/talent.rs`) landed — a sandbox with no client -//! MPQ access still needs a talent tree to exercise `learn_talent`/`reset_talents`/the aura pipeline. A -//! REAL import is a WHOLESALE clear+reload (`DELETE ... WHERE talent_id >= 0` is a tautology on the -//! unsigned key — every row goes, demo ids 1-8 included): importing REPLACES the demo tree, it does not -//! coexist with it. A character who learned a demo talent before the import keeps an orphaned -//! `game_character_talent` row — `apply_learned_talents` logs and skips it at login (respec or -//! `debug_reset_talents` clears it; fine for the no-prod alpha). The reserved 51xxx demo spell ids -//! likewise never collide with imported Spell.dbc ids. An operator who imports for real ends up with BOTH -//! the demo rows (idle, orphaned in tree_id 0-2 alongside real tabs) and the real tree — harmless, since -//! nothing gates on "is this the only row in tree_id N". +//! DEMO SEED (work-item 207): `seed_talents` inserts a small hand-authored Warrior tree (ids 1-8) so a +//! no-DBC sandbox has a talent tree to exercise `learn_talent`/`reset_talents`/the aura pipeline. A real +//! `TalentTab.dbc`/`Talent.dbc` import normally replaces those rows. If imported tabs coexist with a +//! tab-less demo row, the owning learn Gate treats the imported catalogue as authoritative and refuses +//! the demo row. Only a Shard with no `TalentTab` rows may use the Warrior-only demo tree. A character +//! who learned a demo talent before an import keeps an orphaned `game_character_talent` row; +//! `apply_learned_talents` logs and skips it at login. The reserved 51xxx demo spell ids do not collide +//! with imported Spell.dbc ids. use spacetimedb::{table, Identity, ReducerContext, Table}; @@ -48,7 +44,12 @@ use crate::{game_character, game_spell, game_spell_effect, game_world_entity, Sp /// CONTRACT: a talent does something via a PASSIVE aura (`spell_id != 0`) and/or by TEACHING an active /// (`grant_spell_id != 0`) — at least one should be set (both is allowed; the two are applied /// independently). A talent with neither is an inert/malformed seed (logged at login). [static] -#[table(accessor = game_talent, public, index(accessor = by_tree, btree(columns = [tree_id])))] +#[table( + accessor = game_talent, + public, + index(accessor = by_tree, btree(columns = [tree_id])), + index(accessor = by_tab, btree(columns = [tab_id])) +)] pub struct Talent { #[primary_key] pub talent_id: u32, @@ -92,8 +93,8 @@ pub struct Talent { /// `required_talent_id` is 0. END-appended `#[default(0)]`. #[default(0)] pub required_talent_rank: u8, - /// A required SPELL the character must already know (Talent.dbc `required_spell`), 0 = none. NOT - /// gated by `do_learn_talent` this slice (carried for completeness / a future gate). END-appended + /// A required SPELL the character must already know (Talent.dbc `required_spell`), 0 = none. + /// `do_learn_talent` checks this before changing talent, spell, or aura state. END-appended with /// `#[default(0)]`. #[default(0)] pub required_spell_id: u32, @@ -105,7 +106,11 @@ pub struct Talent { /// stays 0). `class_mask`/`race_mask` are Talent.dbc's raw bitmasks (one-hot per class in vanilla); /// `order_index` is the tab's 0-based position within its class (0/1/2), which `importer/src/talent.rs` /// copies onto each of its talents' `tree_id`. [static] -#[table(accessor = game_talent_tab, public)] +#[table( + accessor = game_talent_tab, + public, + index(accessor = by_order, btree(columns = [order_index])) +)] pub struct TalentTab { #[primary_key] pub tab_id: u32, @@ -236,16 +241,24 @@ fn total_spent(ctx: &ReducerContext, guid: u64) -> u32 { .sum() } -/// Points `character_guid` has spent in `tree_id` (sum of learned ranks of talents in that tree). Joins -/// each learned row to its `game_talent` row for the tree id. -fn points_in_tree(ctx: &ReducerContext, guid: u64, tree_id: u8) -> u32 { +/// Points `character_guid` has spent in the selected talent's tree. Imported trees use globally +/// unique `tab_id`; the explicit no-import demo groups its `tab_id == 0` rows by `tree_id`. +fn same_talent_tree(candidate: &Talent, selected: &Talent) -> bool { + if selected.tab_id == 0 { + candidate.tab_id == 0 && candidate.tree_id == selected.tree_id + } else { + candidate.tab_id == selected.tab_id + } +} + +fn points_in_tree(ctx: &ReducerContext, guid: u64, selected: &Talent) -> u32 { let talents = ctx.db.game_talent(); ctx.db .game_character_talent() .by_character() .filter(&guid) .filter_map(|t| match talents.talent_id().find(t.talent_id) { - Some(def) => Some((def.tree_id, t.rank)), + Some(def) => Some((def, t.rank)), None => { // A learned talent with no metadata row would under-count tree points (loosening the // tier gate). game_talent is static + seeded, so this only fires on a data bug — surface it. @@ -256,11 +269,19 @@ fn points_in_tree(ctx: &ReducerContext, guid: u64, tree_id: u8) -> u32 { None } }) - .filter(|(tree, _)| *tree == tree_id) + .filter(|(def, _)| same_talent_tree(def, selected)) .map(|(_, rank)| rank as u32) .sum() } +fn mask_admits(mask: u32, id: u8) -> bool { + id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0 +} + +fn talent_tab_admits(tab: &TalentTab, race: u8, class: u8) -> bool { + mask_admits(tab.class_mask, class) && (tab.race_mask == 0 || mask_admits(tab.race_mask, race)) +} + // =========================================================================================== // Learn + apply // =========================================================================================== @@ -284,9 +305,43 @@ pub(crate) fn do_learn_talent( .map_err(|_| format!("no live entity for guid {guid} (must be in world to learn)"))?; let level = entity.level; + // The no-import demo is Warrior-only. Once any imported tab exists, a tab-less row cannot use + // the demo exception. Imported talents must belong to this Character's class and race before + // any rank, spellbook, or aura state changes. + if talent.tab_id == 0 { + if entity.class() != 1 || ctx.db.game_talent_tab().count() != 0 { + return Err(format!( + "demo talent {talent_id} is not available to class {} with the current catalogue", + entity.class() + )); + } + } else { + let tab = ctx + .db + .game_talent_tab() + .tab_id() + .find(talent.tab_id) + .ok_or_else(|| format!("unknown talent tab {}", talent.tab_id))?; + if !talent_tab_admits(&tab, entity.race(), entity.class()) { + return Err(format!( + "talent {talent_id} is not available to race {} class {}", + entity.race(), + entity.class() + )); + } + } + if talent.required_spell_id != 0 + && !crate::spell::knows_spell(ctx, guid, talent.required_spell_id) + { + return Err(format!( + "talent {talent_id} requires spell {}", + talent.required_spell_id + )); + } + let available = talent_points_available(level, total_spent(ctx, guid)); let current = learned_rank(ctx, guid, talent_id); - let pit = points_in_tree(ctx, guid, talent.tree_id); + let pit = points_in_tree(ctx, guid, &talent); let prereq_ok = prereq_satisfied( talent.required_talent_id, learned_rank(ctx, guid, talent.required_talent_id), @@ -330,6 +385,28 @@ pub(crate) fn do_learn_talent( Ok(next) } +/// Learn one profile-selected talent through the normal talent Gates. The Actor's durable owner is +/// derived here so a Package cannot stamp learned state for another identity. +pub(crate) fn reconcile_profile_talent( + ctx: &ReducerContext, + guid: u64, + talent_id: u32, +) -> Result { + let owner = crate::helpers::live_entity(ctx, guid) + .map_err(|_| { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingActor, + format!("no live entity for guid {guid} (must be in world to learn)"), + ) + })? + .owner_identity; + let before = learned_rank(ctx, guid, talent_id); + let after = do_learn_talent(ctx, guid, owner, talent_id).map_err(|detail| { + crate::actor::ActionRefusal::new(crate::actor::ActionRefusalKind::Other, detail) + })?; + Ok(after != before) +} + /// The spell id for `talent` at `rank`. IMPORTED talents (Talent.dbc) carry a DISTINCT spell per rank /// (`spell_id` = rank 1, `rank_spell_2..5` = ranks 2-5); the hand-authored DEMO tree stores a single /// `spell_id` scaled by rank (its `rank_spell_*` are 0), so a 0 rank-field falls back to `spell_id`. Pure. diff --git a/module/src/trainer.rs b/module/src/trainer.rs index dda21f50f..98f008ae6 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -13,10 +13,10 @@ //! `TrainingFailureReason` codes. [entity] use lyracore_shared::trainer::TrainerRefusal; -use spacetimedb::{table, ReducerContext}; +use spacetimedb::{table, ReducerContext, Table}; use crate::{ - game_creature_template, game_player_skill, game_spell_chain, game_spell_effect, + game_creature_template, game_player_skill, game_spell, game_spell_chain, game_spell_effect, game_world_entity, WorldEntity, }; @@ -159,6 +159,121 @@ pub(crate) fn resolve_learn_target(ctx: &ReducerContext, spell_id: u32) -> u32 { .unwrap_or(spell_id) } +fn demo_profile_spell_admitted(class: u8, spell_id: u32) -> bool { + match class { + 1 => matches!(spell_id, 355 | 7386), + 2 => matches!(spell_id, 635 | 853 | 19740 | 20154 | 20271 | 26573), + 5 => matches!(spell_id, 139 | 2050), + 8 => spell_id == 133, + _ => false, + } +} + +/// Reconcile one free class spell selected by a provisioning profile. Imported Shards require a +/// real offering from a trainer that serves the Character's class, then apply the offering's level +/// and the spell rank-chain Gates. A seed-only Shard has no trainer catalogue, so its supported +/// demo profiles may learn a seeded spell at the spell's own level. +pub(crate) fn reconcile_profile_spell( + ctx: &ReducerContext, + guid: u64, + spell_id: u32, +) -> Result { + if crate::spell::knows_spell(ctx, guid, spell_id) { + return Ok(false); + } + let actor = crate::helpers::live_entity(ctx, guid).map_err(|_| { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingActor, + format!("learner {guid} not in world"), + ) + })?; + let spell = ctx + .db + .game_spell() + .spell_id() + .find(spell_id) + .ok_or_else(|| { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingResource, + format!("no such spell {spell_id}"), + ) + })?; + if actor.level < u32::from(spell.spell_level) { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Level, + format!("spell {spell_id} requires level {}", spell.spell_level), + )); + } + + if ctx.db.game_trainer_spell().count() == 0 { + if !demo_profile_spell_admitted(actor.class(), spell_id) { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Class, + format!( + "spell {spell_id} is not available to class {}", + actor.class() + ), + )); + } + } else { + let offerings: Vec<_> = ctx + .db + .game_trainer_spell() + .iter() + .filter(|offering| { + offering.learn_skill_line == 0 + && resolve_learn_target(ctx, offering.spell_id) == spell_id + && ctx + .db + .game_creature_template() + .entry() + .find(offering.trainer_entry) + .is_some_and(|trainer| { + lyracore_shared::trainer::serves( + actor.class(), + trainer.trainer_type, + trainer.trainer_class, + ) + }) + }) + .collect(); + if offerings.is_empty() { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Class, + format!( + "spell {spell_id} is not offered for class {}", + actor.class() + ), + )); + } + let required_level = offerings + .iter() + .map(|offering| u32::from(offering.required_level)) + .min() + .unwrap_or(0); + if actor.level < required_level { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Level, + format!("spell {spell_id} requires level {required_level}"), + )); + } + } + if let Some(chain) = ctx.db.game_spell_chain().spell_id().find(spell_id) { + if chain.prev_spell != 0 && !crate::spell::knows_spell(ctx, guid, chain.prev_spell) { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Prerequisite, + format!( + "spell {spell_id} requires previous rank {}", + chain.prev_spell + ), + )); + } + } + + crate::spell::learn_spell_with_dependents(ctx, guid, actor.owner_identity, spell_id); + Ok(true) +} + /// Resolve + validate a trainer interaction: `trainer_guid` must be a real in-range TRAINER on `caster`'s /// own map+instance, and one that SERVES `caster`'s class — a Paladin trainer refuses a Warrior /// outright. Shared by [`apply_trainer_buy`] and `talent::do_reset_talents` (the respec path, diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs new file mode 100644 index 000000000..797f1d65c --- /dev/null +++ b/module/tests/playerbots_provisioning.rs @@ -0,0 +1,654 @@ +//! Automatic provisioning against private, seeded Standalone databases. + +mod support; + +use std::collections::BTreeMap; + +use support::Standalone; + +const PB002_CORE: &str = "e6a755db0a150bbf73ad97b972fe829f20f6816c"; +const PB002_PACKAGE: &str = "155c9e401afb06d5731acedf8fc35a81dbe4aaa6"; + +fn fixture(name: &str, class: &str, role: &str) -> (Standalone, String) { + let mut node = Standalone::start(name); + node.publish_module(); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + node.assert_call( + "playerbots_spawn_class_role", + &["1", "1200", "1200", "50", class, role], + ); + let guid = node.query_rows("SELECT character_guid FROM pkg_playerbots_bot")[0] + ["character_guid"] + .clone(); + (node, guid) +} + +fn select(node: &Standalone, guid: &str, controller: &str) { + node.assert_call( + "playerbots_select_controller", + &[guid, &format!("{{\"{controller}\":[]}}")], + ); +} + +fn one(node: &Standalone, query: &str) -> BTreeMap { + node.query_rows(query) + .into_iter() + .next() + .unwrap_or_else(|| panic!("query returned no row: {query}")) +} + +fn item_count(node: &Standalone, guid: &str, entry: u32) -> u32 { + node.query_rows(&format!( + "SELECT stack_count FROM game_item_instance WHERE owner_guid = {guid} AND entry = {entry}" + )) + .iter() + .map(|row| row["stack_count"].parse::().unwrap()) + .sum() +} + +fn health(node: &Standalone, guid: &str) -> u32 { + one( + node, + &format!("SELECT health FROM game_world_entity WHERE guid = {guid}"), + )["health"] + .parse() + .unwrap() +} + +fn gameplay(node: &Standalone, guid: &str) -> serde_json::Value { + serde_json::json!({ + "character": node.query_rows(&format!("SELECT level, xp, money FROM game_character WHERE guid = {guid}")), + "items": node.query_rows(&format!("SELECT entry, slot, stack_count FROM game_item_instance WHERE owner_guid = {guid}")), + "skills": node.query_rows(&format!("SELECT skill_line, current, max_rank FROM game_player_skill WHERE character_guid = {guid}")), + "spells": node.query_rows(&format!("SELECT spell_id FROM game_player_spell WHERE character_guid = {guid}")), + "talents": node.query_rows(&format!("SELECT talent_id, rank FROM game_character_talent WHERE character_guid = {guid}")), + "quests": node.query_rows(&format!("SELECT quest_entry, counts, rewarded FROM game_character_quest WHERE character_guid = {guid}")), + }) +} + +fn git(path: &std::path::Path, args: &[&str]) -> String { + let result = std::process::Command::new("git") + .current_dir(path) + .args(args) + .output() + .unwrap(); + assert!(result.status.success()); + String::from_utf8(result.stdout).unwrap().trim().to_string() +} + +fn digest_files(path: &std::path::Path, digest: &mut blake3::Hasher) { + let mut children: Vec<_> = std::fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + children.sort(); + digest.update(&(children.len() as u64).to_le_bytes()); + for child in children { + let name = child.file_name().unwrap().as_encoded_bytes(); + digest.update(&(name.len() as u64).to_le_bytes()); + digest.update(name); + digest.update(&[u8::from(child.is_dir())]); + if child.is_dir() { + digest_files(&child, digest); + } else { + let contents = std::fs::read(&child).unwrap(); + digest.update(&(contents.len() as u64).to_le_bytes()); + digest.update(&contents); + } + } +} + +fn write_evidence(node: &Standalone, name: &str, mut evidence: serde_json::Value) { + let core = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let package = core.join("packages/playerbots"); + let mut digest = blake3::Hasher::new(); + digest_files(&package, &mut digest); + let fields = evidence.as_object_mut().expect("evidence object"); + fields.insert("rust".to_string(), "1.93.0".into()); + fields.insert("spacetimedb".to_string(), "2.7.1".into()); + fields.insert( + "tested_core".to_string(), + git(core, &["rev-parse", "HEAD"]).into(), + ); + fields.insert( + "tested_collection".to_string(), + git(&package, &["rev-parse", "HEAD"]).into(), + ); + fields.insert( + "core_dirty".to_string(), + (!git(core, &["status", "--porcelain"]).is_empty()).into(), + ); + fields.insert( + "collection_dirty".to_string(), + (!git(&package, &["status", "--porcelain"]).is_empty()).into(), + ); + fields.insert( + "module_wasm_identity".to_string(), + blake3::hash(support::module_bytes()) + .to_hex() + .to_string() + .into(), + ); + fields.insert( + "package_content_identity".to_string(), + digest.finalize().to_hex().to_string().into(), + ); + fields.insert( + "seeded_content_identity".to_string(), + "playerbots-provisioning-v1".into(), + ); + fields.insert( + "seeded_geometry_identity".to_string(), + "playerbots-position-v1".into(), + ); + fields.insert( + "content".to_string(), + serde_json::json!({ + "revision": "playerbots-provisioning-v1", + "profile_catalogue": "seeded class/role kit plus private profile-item fixture rows", + "imported_content": null, + }), + ); + fields.insert( + "geometry".to_string(), + serde_json::json!({ + "revision": "playerbots-seeded-position-v1", + "map": 0, + "instance": 0, + "position": [1200, 1200, 50], + "client_geometry": null, + }), + ); + let path = support::log_dir().join(format!("{}-{name}.json", node.shard_name())); + std::fs::write(path, serde_json::to_vec_pretty(&evidence).unwrap()).unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { + let (node, guid) = fixture("playerbots-provisioning-profile", "1", "0"); + let armed = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + ); + assert!(armed["cause"].to_ascii_lowercase().contains("spawn")); + assert!(armed["history"].is_empty()); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 355" + )) + .is_empty()); + + node.assert_call("debug_set_level", &[&guid, "20"]); + node.assert_sql(&format!( + "DELETE FROM game_player_skill WHERE character_guid = {guid} AND skill_line = 43" + )); + node.assert_sql(&format!( + "DELETE FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" + )); + node.assert_call("playerbots_fixture_provision_catalog", &[]); + select(&node, &guid, "cohort"); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + let runner = one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + ); + assert!(runner["last_outcome"] + .to_ascii_lowercase() + .contains("provisioning")); + assert!(runner["chosen"] + .to_ascii_lowercase() + .contains("provisioning")); + + let before = one( + &node, + &format!("SELECT level, xp, money FROM game_character WHERE guid = {guid}"), + ); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "64"]); + let provisioned = gameplay(&node, &guid); + assert_eq!(item_count(&node, &guid, 4496), 4); + assert_eq!(item_count(&node, &guid, 117), 10); + assert_eq!(item_count(&node, &guid, 118), 5); + assert_eq!(item_count(&node, &guid, 1251), 5); + assert_eq!(item_count(&node, &guid, 2512), 200); + assert_eq!(item_count(&node, &guid, 6948), 1); + assert_eq!( + one( + &node, + &format!( + "SELECT entry FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" + ), + )["entry"], + "25" + ); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_skill WHERE character_guid = {guid} AND skill_line = 43" + )) + .len(), + 1 + ); + assert!(!node + .query_rows(&format!( + "SELECT * FROM game_character_talent WHERE character_guid = {guid}" + )) + .is_empty()); + for kit in + node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0") + { + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = {}", + kit["spell_id"] + )) + .len(), + 1, + "profile spell {}", + kit["spell_id"] + ); + } + let completed = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + ); + let history = completed["history"].to_ascii_lowercase(); + assert!(history.contains("warrior-tank-free")); + assert_eq!(completed["revision"], "1"); + assert!(history.contains("applied")); + for action in ["skill", "spell", "talent", "item", "equip"] { + assert!( + history.contains(action), + "missing {action} decision: {history}" + ); + } + assert!(completed["cause"].to_ascii_lowercase().contains("periodic")); + + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "64"]); + assert_eq!(gameplay(&node, &guid), provisioned); + let after = one( + &node, + &format!("SELECT level, xp, money FROM game_character WHERE guid = {guid}"), + ); + assert_eq!(after, before); + + let repair_spell = node + .query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0")[0] + ["spell_id"] + .clone(); + node.assert_sql(&format!( + "DELETE FROM game_player_spell WHERE character_guid = {guid} AND spell_id = {repair_spell}" + )); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "64"]); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = {repair_spell}" + )) + .len(), + 1 + ); + write_evidence( + &node, + "profile", + serde_json::json!({ + "armed": armed, + "runner": runner, + "provisioning": one(&node, &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}")), + "gameplay": gameplay(&node, &guid), + }), + ); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_yields_to_recovery_then_supplies_missing_recovery() { + let (node, guid) = fixture("playerbots-provisioning-priority", "5", "1"); + node.assert_call("playerbots_fixture_provision_catalog", &[]); + select(&node, &guid, "cohort"); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "64"]); + assert_eq!(item_count(&node, &guid, 159), 10); + assert_eq!(item_count(&node, &guid, 17029), 5); + node.assert_call("playerbots_fixture_provision_reset", &[&guid]); + node.assert_call("playerbots_fixture_prepare", &[]); + node.assert_call("playerbots_fixture_runner_stage", &[&guid, "true"]); + node.assert_call("playerbots_fixture_position", &[&guid, "1240"]); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + let urgent = one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + ); + assert!(urgent["chosen"].to_ascii_lowercase().contains("recovery")); + assert!(!urgent["last_outcome"] + .to_ascii_lowercase() + .contains("provisioning")); + assert!(one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}") + )["history"] + .is_empty()); + + node.assert_call("playerbots_fixture_cancel", &[&guid, "false"]); + node.assert_call("playerbots_fixture_provision_remove_recovery", &[&guid]); + assert_eq!(item_count(&node, &guid, 117), 9); + let wounded_health = health(&node, &guid); + node.assert_call("playerbots_fixture_runner_pass", &[]); + assert_eq!(item_count(&node, &guid, 117), 10); + let supplied = one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + ); + assert!(supplied["last_outcome"] + .to_ascii_lowercase() + .contains("provisioning")); + node.assert_call("playerbots_fixture_provision_due", &[&guid]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + assert_eq!(item_count(&node, &guid, 117), 9); + assert!(!node + .query_rows(&format!( + "SELECT * FROM game_aura WHERE target_guid = {guid} AND spell_id = 50115" + )) + .is_empty()); + assert!(support::poll_until(support::POLL_TIMEOUT, || { + health(&node, &guid) > wounded_health + })); + let history = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(history.matches("recoverhealth").count() >= 2); + assert!(history.matches("applied").count() >= 2); + write_evidence( + &node, + "priority", + serde_json::json!({"urgent_runner": urgent, "supplied_runner": supplied, "history": history}), + ); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { + let (node, guid) = fixture("playerbots-provisioning-stops", "1", "0"); + select(&node, &guid, "cohort"); + node.assert_call("playerbots_fixture_provision_full_bag", &[&guid]); + let before = gameplay(&node, &guid); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + assert_eq!(item_count(&node, &guid, 5_090_150), 1); + assert_eq!(item_count(&node, &guid, 117), 0); + let stopped = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(stopped.contains("stopped")); + assert!(stopped.contains("inventoryfull")); + let history_before = stopped.clone(); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + assert_eq!( + one( + &node, + &format!( + "SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}" + ) + )["history"] + .to_ascii_lowercase(), + history_before + ); + assert!(!one( + &node, + &format!("SELECT last_outcome FROM pkg_playerbots_runner WHERE character_guid = {guid}") + )["last_outcome"] + .to_ascii_lowercase() + .contains("provisioning")); + let after_useful_tick = gameplay(&node, &guid); + for field in ["character", "items", "quests", "spells", "talents"] { + assert_eq!( + after_useful_tick[field], before[field], + "gameplay field {field}" + ); + } + + node.assert_call("playerbots_fixture_provision_missing_resource", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let missing = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(missing.contains("refused")); + assert!(missing.contains("missingresource")); + node.assert_call("playerbots_fixture_provision_wrong_class_spell", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let wrong_class = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(wrong_class.contains("refused")); + assert!(wrong_class.contains("class")); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 133" + )) + .is_empty()); + node.assert_call("playerbots_fixture_provision_profile_overflow", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let overflow = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(overflow.contains("stopped")); + assert!(overflow.contains("profilelimit")); + node.assert_call("playerbots_fixture_provision_dead", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let dead = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(dead.contains("deferred")); + assert!(dead.contains("dead")); + + let (gear_node, gear_guid) = fixture("playerbots-provisioning-gear", "1", "0"); + select(&gear_node, &gear_guid, "cohort"); + gear_node.assert_call( + "playerbots_fixture_provision_stronger_weapon", + &[&gear_guid], + ); + gear_node.assert_call("playerbots_fixture_provision_steps", &[&gear_guid, "1"]); + let mainhand = one( + &gear_node, + &format!( + "SELECT entry FROM game_item_instance WHERE owner_guid = {gear_guid} AND slot = 15" + ), + ); + assert_eq!(mainhand["entry"], "50"); + write_evidence( + &node, + "stops", + serde_json::json!({"stopped": stopped, "missing": missing, "wrong_class": wrong_class, "overflow": overflow, "dead": dead, "gameplay": gameplay(&node, &guid)}), + ); + write_evidence( + &gear_node, + "gear", + serde_json::json!({"mainhand": mainhand, "gameplay": gameplay(&gear_node, &gear_guid)}), + ); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_arms_levelup_but_control_modes_block_gameplay() { + let (node, guid) = fixture("playerbots-provisioning-control", "1", "0"); + let initial = gameplay(&node, &guid); + for controller in ["legacy", "recordOnly", "frozen"] { + select(&node, &guid, controller); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "4"]); + assert_eq!(gameplay(&node, &guid), initial, "{controller}"); + } + select(&node, &guid, "recordOnly"); + node.assert_call("playerbots_fixture_provision_levelup", &[&guid]); + let armed = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + ); + assert!(armed["cause"].to_ascii_lowercase().contains("levelup")); + assert!(armed["armed_level"].parse::().unwrap() > 9); + assert!(armed["history"].is_empty()); + let levelled = gameplay(&node, &guid); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "4"]); + assert_eq!(gameplay(&node, &guid), levelled); + write_evidence( + &node, + "control", + serde_json::json!({"armed": armed, "gameplay": levelled}), + ); +} + +fn talent_state(node: &Standalone, guid: &str) -> serde_json::Value { + serde_json::json!({ + "talents": node.query_rows(&format!("SELECT talent_id, rank FROM game_character_talent WHERE character_guid = {guid}")), + "spells": node.query_rows(&format!("SELECT spell_id FROM game_player_spell WHERE character_guid = {guid}")), + "auras": node.query_rows(&format!("SELECT spell_id, effect_id FROM game_aura WHERE target_guid = {guid}")), + }) +} + +fn assert_talent_refused_unchanged(node: &Standalone, guid: &str, talent: &str) { + let before = talent_state(node, guid); + let output = node.call("debug_learn_talent", &[guid, talent]); + assert!( + !output.status.success(), + "talent {talent} unexpectedly learned" + ); + assert_eq!(talent_state(node, guid), before, "talent {talent}"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_talent_gates_are_atomic_and_use_global_tab_identity() { + let (node, mage) = fixture("playerbots-provisioning-talents", "8", "2"); + node.assert_call("debug_set_level", &[&mage, "20"]); + assert_talent_refused_unchanged(&node, &mage, "1"); + + node.assert_call( + "playerbots_spawn_class_role", + &["1", "1200", "1200", "50", "1", "0"], + ); + let warrior = node + .query_rows("SELECT character_guid FROM pkg_playerbots_bot") + .into_iter() + .map(|row| row["character_guid"].clone()) + .find(|guid| guid != &mage) + .unwrap(); + node.assert_call("debug_set_level", &[&warrior, "20"]); + for (tab, class_mask, race_mask) in [(9001, 128, 0), (9002, 1, 0), (9003, 1, 0), (9004, 1, 2)] { + node.assert_sql(&format!( + "INSERT INTO game_talent_tab (tab_id,name,class_mask,race_mask,order_index) VALUES ({tab},'PB005 tab',{class_mask},{race_mask},0)" + )); + } + assert_talent_refused_unchanged(&node, &warrior, "1"); + + let insert_talent = |id: u32, + tab: u32, + max_rank: u8, + required_points: u32, + required_spell: u32| { + node.assert_sql(&format!( + "INSERT INTO game_talent (talent_id,name,tree_id,tier,column,max_rank,spell_id,required_talent_id,required_points_in_tree,grant_spell_id,tab_id,rank_spell_2,rank_spell_3,rank_spell_4,rank_spell_5,required_talent_rank,required_spell_id) VALUES ({id},'PB005 talent',0,0,0,{max_rank},0,0,{required_points},0,{tab},0,0,0,0,0,{required_spell})" + )); + }; + insert_talent(9001, 9001, 1, 0, 0); + assert_talent_refused_unchanged(&node, &warrior, "9001"); + insert_talent(9004, 9004, 1, 0, 0); + assert_talent_refused_unchanged(&node, &warrior, "9004"); + insert_talent(9002, 9002, 1, 0, 999_999); + assert_talent_refused_unchanged(&node, &warrior, "9002"); + + insert_talent(9010, 9002, 5, 0, 0); + insert_talent(9020, 9003, 1, 5, 0); + for _ in 0..5 { + node.assert_call("debug_learn_talent", &[&warrior, "9010"]); + } + assert_talent_refused_unchanged(&node, &warrior, "9020"); + write_evidence( + &node, + "talent-gates", + serde_json::json!({"mage": talent_state(&node, &mage), "warrior": talent_state(&node, &warrior)}), + ); +} + +#[test] +#[ignore = "requires the integrated PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn playerbots_provisioning_migrates_populated_pb002_runner_state() { + let old_path = std::env::var_os("PLAYERBOTS_PB002_WASM") + .expect("PLAYERBOTS_PB002_WASM must name the integrated PB-002 Wasm"); + let old_wasm = std::fs::read(old_path).unwrap(); + assert_ne!( + blake3::hash(&old_wasm), + blake3::hash(support::module_bytes()) + ); + let mut node = Standalone::start("playerbots-provisioning-pb002-upgrade"); + node.publish_module_bytes(&old_wasm); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + node.assert_call("playerbots_spawn_role", &["1", "1200", "1200", "50", "1"]); + let guid = node.query_rows("SELECT character_guid FROM pkg_playerbots_bot")[0] + ["character_guid"] + .clone(); + node.assert_call("playerbots_fixture_runner_stage", &[&guid, "false"]); + select(&node, &guid, "cohort"); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + assert!(support::poll_until(support::POLL_TIMEOUT, || { + one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + )["foreground"] + .to_ascii_lowercase() + .contains("movement") + })); + let preceding = one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + ); + assert!(preceding["objective"] + .to_ascii_lowercase() + .contains("returnhome")); + assert!(preceding["foreground"] + .to_ascii_lowercase() + .contains("movement")); + + node.publish_module(); + let upgraded = one( + &node, + &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), + ); + for field in ["objective_sequence", "objective", "foreground", "chosen"] { + assert_eq!(upgraded[field], preceding[field], "runner field {field}"); + } + assert!(node + .query_rows(&format!( + "SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}" + )) + .is_empty()); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + write_evidence( + &node, + "pb002-upgrade", + serde_json::json!({ + "preceding_core": PB002_CORE, + "preceding_package": PB002_PACKAGE, + "preceding_wasm_identity": blake3::hash(&old_wasm).to_hex().to_string(), + "current_wasm_identity": blake3::hash(support::module_bytes()).to_hex().to_string(), + "preceding_runner": preceding, + "upgraded_runner": upgraded, + "provisioning": node.query_rows(&format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}")), + }), + ); +} From 12383f28a4cab79828647abd60d86c149226146f Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:47:57 +0000 Subject: [PATCH 02/19] test(playerbots): freeze migration foreground --- module/tests/playerbots_provisioning.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 797f1d65c..cfc291be3 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -612,6 +612,7 @@ fn playerbots_provisioning_migrates_populated_pb002_runner_state() { .to_ascii_lowercase() .contains("movement") })); + node.assert_call("playerbots_fixture_freeze", &[&guid]); let preceding = one( &node, &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), From 13aca9a1418e3db075474ba77938ac27204489bb Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:15:08 +0000 Subject: [PATCH 03/19] test(playerbots): stage complete profile content --- module/tests/playerbots_provisioning.rs | 42 +++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index cfc291be3..c484471e0 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -190,6 +190,7 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { "DELETE FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" )); node.assert_call("playerbots_fixture_provision_catalog", &[]); + node.assert_call("playerbots_fixture_provision_complete_profile", &[&guid]); select(&node, &guid, "cohort"); node.assert_call("playerbots_fixture_runner_due", &[]); node.assert_call("playerbots_fixture_runner_pass", &[]); @@ -209,7 +210,24 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { &format!("SELECT level, xp, money FROM game_character WHERE guid = {guid}"), ); node.assert_call("playerbots_fixture_provision_steps", &[&guid, "64"]); + let completed = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + ); + let history = completed["history"].to_ascii_lowercase(); let provisioned = gameplay(&node, &guid); + write_evidence( + &node, + "profile", + serde_json::json!({ + "status": "captured before completion assertions", + "armed": armed, + "runner": runner, + "provisioning": completed, + "gameplay": provisioned, + "fixture_profile_spells": node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0"), + }), + ); assert_eq!(item_count(&node, &guid, 4496), 4); assert_eq!(item_count(&node, &guid, 117), 10); assert_eq!(item_count(&node, &guid, 118), 5); @@ -251,11 +269,6 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { kit["spell_id"] ); } - let completed = one( - &node, - &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), - ); - let history = completed["history"].to_ascii_lowercase(); assert!(history.contains("warrior-tank-free")); assert_eq!(completed["revision"], "1"); assert!(history.contains("applied")); @@ -298,6 +311,7 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { "runner": runner, "provisioning": one(&node, &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}")), "gameplay": gameplay(&node, &guid), + "fixture_profile_spells": node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0"), }), ); } @@ -422,6 +436,22 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { .to_ascii_lowercase(); assert!(missing.contains("refused")); assert!(missing.contains("missingresource")); + node.assert_call("playerbots_fixture_provision_missing_spell", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let missing_spell = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + assert!(missing_spell.contains("spell")); + assert!(missing_spell.contains("7386")); + assert!(missing_spell.contains("refused")); + assert!(missing_spell.contains("missingresource")); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 7386" + )) + .is_empty()); node.assert_call("playerbots_fixture_provision_wrong_class_spell", &[&guid]); node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); let wrong_class = one( @@ -472,7 +502,7 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { write_evidence( &node, "stops", - serde_json::json!({"stopped": stopped, "missing": missing, "wrong_class": wrong_class, "overflow": overflow, "dead": dead, "gameplay": gameplay(&node, &guid)}), + serde_json::json!({"stopped": stopped, "missing_item": missing, "missing_spell": missing_spell, "wrong_class": wrong_class, "overflow": overflow, "dead": dead, "gameplay": gameplay(&node, &guid)}), ); write_evidence( &gear_node, From da5c291f0eabed334c36efb7852452d9caf2b00e Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:07:23 +0000 Subject: [PATCH 04/19] fix(playerbots): bound profile admission --- CONTEXT.md | 5 + docs/package-api.md | 8 +- module/src/actor.rs | 3 + module/src/items/ops.rs | 37 +++- module/src/spell/proc.rs | 17 -- module/src/spell/tables.rs | 7 +- module/src/talent.rs | 206 ++++++++++++++---- module/src/trainer.rs | 267 +++++++++++++++++++----- module/tests/playerbots_provisioning.rs | 123 +++++++++++ 9 files changed, 544 insertions(+), 129 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 1b643a8d8..5369ae2ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -204,6 +204,11 @@ _Avoid_: invite intent, group request, party order The durable selector for Legacy, RecordOnly, Cohort, or Frozen behavior. RecordOnly records decisions and authorizes no bot gameplay. Frozen cancels Foreground Actions and authorizes no new bot gameplay. +**Provisioning Profile**: +A revisioned, bounded upkeep policy for a supported bot class and role. It selects free training and +owned supplies; Module Gates decide every operation. It is distinct from a World Import Profile, +which scopes imported world content. + **Bot Objective**: A retained purpose with its destination, stage, deadline, catalog revision, and verified progress. Tactical interruption does not replace the objective. A companion in a human-led party retains the diff --git a/docs/package-api.md b/docs/package-api.md index 45fc3d1a4..7ea1564f3 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -102,9 +102,11 @@ supported-lifecycle, range, line-of-sight, and ordinary cast Gates used at cast turn an `OutOfRange` or `NoLineOfSight` Refusal into a movement prerequisite without spending power, starting a cooldown, or creating a cast. -Provisioning uses five Actor verbs. `reconcile_profile_spell`, `reconcile_profile_skill`, and -`learn_profile_talent` return `Result`; `true` means they changed normal -Character state and `false` means the selected profile entry was already satisfied. +Provisioning uses six Actor operations. `select_profile_talent` performs a bounded preferred-tree +read through the owning talent admission calculation. `reconcile_profile_spell`, +`reconcile_profile_skill`, and `learn_profile_talent` return `Result`; `true` +means they changed normal Character state and `false` means the selected profile entry was already +satisfied. `reconcile_profile_item` returns the granted count and caps its target at 200. `equip_profile_upgrade` returns whether it equipped the carried item and preserves equal or stronger gear. These verbs do not authorize an Actor. A Package must first pass the same controller and diff --git a/module/src/actor.rs b/module/src/actor.rs index 1baaa5b36..c920b207a 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -30,6 +30,7 @@ //! | `trainer_buy` | `trainer::apply_trainer_buy` | trainer resolved by guid + offering gates (class/level/money) | //! | `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback | //! | `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates | +//! | `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent Gates | //! | `learn_profile_talent` | `talent::do_learn_talent` | class/race/level/point/tier/prerequisite gates | //! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | //! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | @@ -144,6 +145,7 @@ package_only! { pub(crate) use crate::trainer::reconcile_profile_spell as reconcile_profile_spell; pub(crate) use crate::skill::reconcile_profile_skill as reconcile_profile_skill; pub(crate) use crate::talent::reconcile_profile_talent as learn_profile_talent; + pub(crate) use crate::talent::select_profile_talent as select_profile_talent; } // ---- NPC services / world ---- @@ -189,6 +191,7 @@ pub enum ActionRefusalKind { Class, Level, Prerequisite, + ProfileLimit, } impl ActionRefusal { diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 10c52a126..9db2ead4e 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -212,7 +212,7 @@ pub(crate) fn request_grant_item( ) } -/// Top a profile-owned carried resource up to a fixed target. The target is capped at the largest +/// Top a profile-owned resource up to a fixed target. The target is capped at the largest /// supported ammunition stack so a malformed profile cannot turn periodic repair into an unlimited /// grant. Storage still owns capacity, uniqueness, and Random Property Gates. pub(crate) fn request_profile_item( @@ -221,13 +221,7 @@ pub(crate) fn request_profile_item( item_entry: u32, target_count: u32, ) -> Result { - const PROFILE_ITEM_LIMIT: u32 = 200; - if target_count == 0 || target_count > PROFILE_ITEM_LIMIT { - return Err(ActionRefusal::new( - crate::actor::ActionRefusalKind::Other, - format!("profile item target {target_count} is outside 1..={PROFILE_ITEM_LIMIT}"), - )); - } + profile_item_target_admitted(target_count)?; let owned = ctx .db .game_item_instance() @@ -244,6 +238,18 @@ pub(crate) fn request_profile_item( Ok(missing) } +const PROFILE_ITEM_LIMIT: u32 = 200; + +fn profile_item_target_admitted(target_count: u32) -> Result<(), ActionRefusal> { + if (1..=PROFILE_ITEM_LIMIT).contains(&target_count) { + return Ok(()); + } + Err(ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!("profile item target {target_count} is outside 1..={PROFILE_ITEM_LIMIT}"), + )) +} + /// Add items to matching carried stacks, then free backpack or bag slots. /// Capacity, uniqueness and every Random Property are resolved before changing any item, so callers may /// handle an inventory-full Refusal without keeping a partial grant. @@ -917,9 +923,22 @@ pub(crate) fn apply_take_loot( #[cfg(test)] mod tests { use super::{ - bandage_cooldown_blocks, death_durability_loss, use_spell_for, RECENTLY_BANDAGED_SPELL, + bandage_cooldown_blocks, death_durability_loss, profile_item_target_admitted, + use_spell_for, RECENTLY_BANDAGED_SPELL, }; + #[test] + fn profile_item_target_refuses_zero_and_over_limit_counts() { + for target in [0, 201] { + assert_eq!( + profile_item_target_admitted(target).unwrap_err().kind, + crate::actor::ActionRefusalKind::ProfileLimit + ); + } + assert!(profile_item_target_admitted(1).is_ok()); + assert!(profile_item_target_admitted(200).is_ok()); + } + /// `use_spell_for`: `spellid_1` is the on-use spell IFF it's nonzero AND `spelltrigger_1` names /// the on-use trigger slot (0 — `ItemSpellTriggerType::OnUse`). A trigger-1 (on-equip) spell is NOT /// an on-use cast even with a nonzero id, and a zero `spellid_1` (a plain reagent/gear piece/ diff --git a/module/src/spell/proc.rs b/module/src/spell/proc.rs index 315b4b90d..1464a70fd 100644 --- a/module/src/spell/proc.rs +++ b/module/src/spell/proc.rs @@ -1195,21 +1195,4 @@ mod tests { pipeline. Body was:\n{body}" ); } - - /// The trainer's wrapper-resolution exclusion list names the renamed Proc kind. It and the - /// importer's `wrapper_to_rank` heuristic are the same rule on the two sides of the wire: a - /// trigger spell on a Proc aura is an effect PAYLOAD, not a rank to learn. - #[test] - fn the_learn_target_resolution_excludes_the_proc_trigger_kind() { - let src = crate::test_scan::read_scanned("module/src/trainer.rs") - .expect("module/ is never optional"); - let body = crate::test_scan::code_of(&src, "pub(crate) fn resolve_learn_target("); - for kind in ["A_PROC_TRIGGER", "A_PROC_DAMAGE"] { - assert!( - body.contains(kind), - "`resolve_learn_target` no longer excludes `{kind}` — buying Frost Armor rank 2 would \ - teach `Chilled` instead. Body was:\n{body}" - ); - } - } } diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index 8b897a846..785405303 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -87,7 +87,12 @@ pub struct Spell { /// `p0_kind` (the importer resolves `EffectMiscValue` into these ONCE, so the runtime never asks "what /// does this int mean"). `id` is DETERMINISTIC `(spell_id<<2)|effect_index` (the seeder computes it, NOT /// auto_inc) so seed/SQL rows are stable and `game_aura.effect_id` is reproducible. [static] -#[table(accessor = game_spell_effect, public, index(accessor = by_spell, btree(columns = [spell_id])))] +#[table( + accessor = game_spell_effect, + public, + index(accessor = by_spell, btree(columns = [spell_id])), + index(accessor = by_trigger_spell, btree(columns = [trigger_spell])) +)] pub struct SpellEffect { #[primary_key] pub id: u64, // = (spell_id<<2)|effect_index, computed by the author/importer diff --git a/module/src/talent.rs b/module/src/talent.rs index efddca9da..fba0d285e 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -231,14 +231,11 @@ fn learned_rank(ctx: &ReducerContext, guid: u64, talent_id: u32) -> u8 { .unwrap_or(0) } -/// Total talent points `character_guid` has spent (sum of all learned ranks). -fn total_spent(ctx: &ReducerContext, guid: u64) -> u32 { - ctx.db - .game_character_talent() - .by_character() - .filter(&guid) - .map(|t| t.rank as u32) - .sum() +fn rank_in(learned: &[CharacterTalent], talent_id: u32) -> u8 { + learned + .iter() + .find(|row| row.talent_id == talent_id) + .map_or(0, |row| row.rank) } /// Points `character_guid` has spent in the selected talent's tree. Imported trees use globally @@ -251,12 +248,15 @@ fn same_talent_tree(candidate: &Talent, selected: &Talent) -> bool { } } -fn points_in_tree(ctx: &ReducerContext, guid: u64, selected: &Talent) -> u32 { +fn points_in_tree( + ctx: &ReducerContext, + guid: u64, + learned: &[CharacterTalent], + selected: &Talent, +) -> u32 { let talents = ctx.db.game_talent(); - ctx.db - .game_character_talent() - .by_character() - .filter(&guid) + learned + .iter() .filter_map(|t| match talents.talent_id().find(t.talent_id) { Some(def) => Some((def, t.rank)), None => { @@ -282,36 +282,30 @@ fn talent_tab_admits(tab: &TalentTab, race: u8, class: u8) -> bool { mask_admits(tab.class_mask, class) && (tab.race_mask == 0 || mask_admits(tab.race_mask, race)) } -// =========================================================================================== -// Learn + apply -// =========================================================================================== - -/// Shared validated core of learning a talent (the player + debug reducers both call this). Validates -/// against the live entity's level + the character's learned set, increments the rank, and applies the -/// talent's passive aura at the new rank so it takes effect immediately (no relog). Returns the new rank. -pub(crate) fn do_learn_talent( +fn admitted_talent_rank( ctx: &ReducerContext, guid: u64, - owner: Identity, - talent_id: u32, + entity: &crate::WorldEntity, + learned: &[CharacterTalent], + talent: &Talent, ) -> Result { - let talent = ctx - .db - .game_talent() - .talent_id() - .find(talent_id) - .ok_or_else(|| format!("unknown talent {talent_id}"))?; - let entity = crate::helpers::live_entity(ctx, guid) - .map_err(|_| format!("no live entity for guid {guid} (must be in world to learn)"))?; - let level = entity.level; + let spent_in_tree = points_in_tree(ctx, guid, learned, talent); + admitted_talent_rank_with_points(ctx, guid, entity, learned, talent, spent_in_tree) +} - // The no-import demo is Warrior-only. Once any imported tab exists, a tab-less row cannot use - // the demo exception. Imported talents must belong to this Character's class and race before - // any rank, spellbook, or aura state changes. +fn admitted_talent_rank_with_points( + ctx: &ReducerContext, + guid: u64, + entity: &crate::WorldEntity, + learned: &[CharacterTalent], + talent: &Talent, + spent_in_tree: u32, +) -> Result { if talent.tab_id == 0 { if entity.class() != 1 || ctx.db.game_talent_tab().count() != 0 { return Err(format!( - "demo talent {talent_id} is not available to class {} with the current catalogue", + "demo talent {} is not available to class {} with the current catalogue", + talent.talent_id, entity.class() )); } @@ -324,7 +318,8 @@ pub(crate) fn do_learn_talent( .ok_or_else(|| format!("unknown talent tab {}", talent.tab_id))?; if !talent_tab_admits(&tab, entity.race(), entity.class()) { return Err(format!( - "talent {talent_id} is not available to race {} class {}", + "talent {} is not available to race {} class {}", + talent.talent_id, entity.race(), entity.class() )); @@ -334,27 +329,57 @@ pub(crate) fn do_learn_talent( && !crate::spell::knows_spell(ctx, guid, talent.required_spell_id) { return Err(format!( - "talent {talent_id} requires spell {}", - talent.required_spell_id + "talent {} requires spell {}", + talent.talent_id, talent.required_spell_id )); } - let available = talent_points_available(level, total_spent(ctx, guid)); - let current = learned_rank(ctx, guid, talent_id); - let pit = points_in_tree(ctx, guid, &talent); + let spent = learned.iter().map(|row| u32::from(row.rank)).sum(); + let available = talent_points_available(entity.level, spent); + let current = rank_in(learned, talent.talent_id); let prereq_ok = prereq_satisfied( talent.required_talent_id, - learned_rank(ctx, guid, talent.required_talent_id), + rank_in(learned, talent.required_talent_id), talent.required_talent_rank, ); - let next = validate_learn( + validate_learn( current, talent.max_rank, available, - pit, + spent_in_tree, talent.required_points_in_tree, prereq_ok, - )?; + ) +} + +// =========================================================================================== +// Learn + apply +// =========================================================================================== + +/// Shared validated core of learning a talent (the player + debug reducers both call this). Validates +/// against the live entity's level + the character's learned set, increments the rank, and applies the +/// talent's passive aura at the new rank so it takes effect immediately (no relog). Returns the new rank. +pub(crate) fn do_learn_talent( + ctx: &ReducerContext, + guid: u64, + owner: Identity, + talent_id: u32, +) -> Result { + let talent = ctx + .db + .game_talent() + .talent_id() + .find(talent_id) + .ok_or_else(|| format!("unknown talent {talent_id}"))?; + let entity = crate::helpers::live_entity(ctx, guid) + .map_err(|_| format!("no live entity for guid {guid} (must be in world to learn)"))?; + let learned: Vec<_> = ctx + .db + .game_character_talent() + .by_character() + .filter(&guid) + .collect(); + let next = admitted_talent_rank(ctx, guid, &entity, &learned, &talent)?; // Upsert the learned rank. let talents = ctx.db.game_character_talent(); @@ -407,6 +432,95 @@ pub(crate) fn reconcile_profile_talent( Ok(after != before) } +const PROFILE_TALENT_LIMIT: usize = 64; +const PROFILE_TALENT_TAB_LIMIT: usize = 16; + +/// Select the next talent in a profile-owned preferred tree through the same admission calculation +/// as `do_learn_talent`. The indexed reads accept at most 64 learned rows, 16 tabs at the requested +/// tree position, and 64 talent candidates, with one extra row per set for overflow detection. One +/// exact talent-definition read per learned row computes the shared tree-point total. Admission then +/// performs one exact tab read and at most one exact required-spell read per candidate. +pub(crate) fn select_profile_talent( + ctx: &ReducerContext, + guid: u64, + preferred_tree: u8, +) -> Result, crate::actor::ActionRefusal> { + let entity = crate::helpers::live_entity(ctx, guid).map_err(|_| { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::MissingActor, + format!("learner {guid} not in world"), + ) + })?; + let learned: Vec<_> = ctx + .db + .game_character_talent() + .by_character() + .filter(&guid) + .take(PROFILE_TALENT_LIMIT + 1) + .collect(); + if learned.len() > PROFILE_TALENT_LIMIT { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!("character {guid} has more than {PROFILE_TALENT_LIMIT} learned talent rows"), + )); + } + let imported = ctx.db.game_talent_tab().count() != 0; + let tabs: Vec<_> = ctx + .db + .game_talent_tab() + .by_order() + .filter(&preferred_tree) + .take(PROFILE_TALENT_TAB_LIMIT + 1) + .collect(); + if tabs.len() > PROFILE_TALENT_TAB_LIMIT { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!("talent order {preferred_tree} has more than {PROFILE_TALENT_TAB_LIMIT} tabs"), + )); + } + let tab = tabs + .into_iter() + .filter(|tab| talent_tab_admits(tab, entity.race(), entity.class())) + .map(|tab| tab.tab_id) + .min(); + if imported && tab.is_none() { + return Ok(None); + } + let mut candidates: Vec<_> = if let Some(tab_id) = tab { + ctx.db + .game_talent() + .by_tab() + .filter(&tab_id) + .take(PROFILE_TALENT_LIMIT + 1) + .collect() + } else if entity.class() == 1 { + ctx.db + .game_talent() + .by_tree() + .filter(&preferred_tree) + .filter(|talent| talent.tab_id == 0) + .take(PROFILE_TALENT_LIMIT + 1) + .collect() + } else { + vec![] + }; + if candidates.len() > PROFILE_TALENT_LIMIT { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!("talent tab has more than {PROFILE_TALENT_LIMIT} rows"), + )); + } + candidates.sort_by_key(|talent| (talent.tier, talent.column, talent.talent_id)); + let spent_in_tree = candidates + .first() + .map_or(0, |talent| points_in_tree(ctx, guid, &learned, talent)); + Ok(candidates.into_iter().find_map(|talent| { + admitted_talent_rank_with_points(ctx, guid, &entity, &learned, &talent, spent_in_tree) + .is_ok() + .then_some(talent.talent_id) + })) +} + /// The spell id for `talent` at `rank`. IMPORTED talents (Talent.dbc) carry a DISTINCT spell per rank /// (`spell_id` = rank 1, `rank_spell_2..5` = ranks 2-5); the hand-authored DEMO tree stores a single /// `spell_id` scaled by rank (its `rank_spell_*` are 0), so a 0 rank-field falls back to `spell_id`. Pure. diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 98f008ae6..0aff44f6a 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -36,7 +36,12 @@ pub(crate) fn refused(refusal: TrainerRefusal, detail: &str) -> String { /// One spell a trainer teaches. Static, public, SQL-loadable (no Timestamp), keyed by the trainer's /// CREATURE TEMPLATE entry (every spawned trainer of that entry offers the same list — like vendors). /// Logical key `(trainer_entry, spell_id)` via the `#[auto_inc]` PK + `by_trainer` btree. [static] -#[table(accessor = game_trainer_spell, public, index(accessor = by_trainer, btree(columns = [trainer_entry])))] +#[table( + accessor = game_trainer_spell, + public, + index(accessor = by_trainer, btree(columns = [trainer_entry])), + index(accessor = by_spell, btree(columns = [spell_id])) +)] pub struct TrainerSpell { #[primary_key] #[auto_inc] @@ -142,21 +147,217 @@ pub(crate) fn trainer_buy_check( /// the same rule on the two sides of the wire. Generic over the kind. Shared by `apply_trainer_buy` /// (the player buy) and the playerbots trainer-kit pass (work-item 156) — ONE wrapper-resolution /// chokepoint, so a bot's spellbook and a trained player's can never drift. [entity] +fn learn_target_trigger(effect: &crate::SpellEffect) -> Option { + (effect.trigger_spell != 0 + && effect.kind != crate::spell::A_PERIODIC_TRIGGER + && effect.kind != crate::spell::A_FLAG + && effect.kind != crate::spell::A_PROC_TRIGGER + && effect.kind != crate::spell::A_PROC_DAMAGE + && effect.kind != crate::spell::E_TRIGGER) + .then_some(effect.trigger_spell) +} + +fn learn_target_from_effects(spell_id: u32, effects: &[crate::SpellEffect]) -> u32 { + effects + .iter() + .filter_map(|effect| { + learn_target_trigger(effect).map(|target| (effect.effect_index, effect.id, target)) + }) + .min_by_key(|(effect_index, id, _)| (*effect_index, *id)) + .map(|(_, _, target)| target) + .unwrap_or(spell_id) +} + pub(crate) fn resolve_learn_target(ctx: &ReducerContext, spell_id: u32) -> u32 { - ctx.db + let effects: Vec<_> = ctx + .db .game_spell_effect() .by_spell() .filter(&spell_id) - .find_map(|e| { - (e.trigger_spell != 0 - && e.kind != crate::spell::A_PERIODIC_TRIGGER - && e.kind != crate::spell::A_FLAG - && e.kind != crate::spell::A_PROC_TRIGGER - && e.kind != crate::spell::A_PROC_DAMAGE - && e.kind != crate::spell::E_TRIGGER) - .then_some(e.trigger_spell) - }) - .unwrap_or(spell_id) + .collect(); + learn_target_from_effects(spell_id, &effects) +} + +const PROFILE_TRAINER_OFFERING_LIMIT: usize = 16; +const PROFILE_WRAPPER_EFFECT_LIMIT: usize = 16; +const PROFILE_SPELL_EFFECT_LIMIT: usize = 3; + +fn profile_limit(detail: impl Into) -> crate::actor::ActionRefusal { + crate::actor::ActionRefusal::new(crate::actor::ActionRefusalKind::ProfileLimit, detail.into()) +} + +fn profile_learn_target( + ctx: &ReducerContext, + wrapper_spell: u32, +) -> Result { + let effects: Vec<_> = ctx + .db + .game_spell_effect() + .by_spell() + .filter(&wrapper_spell) + .take(PROFILE_SPELL_EFFECT_LIMIT + 1) + .collect(); + if effects.len() > PROFILE_SPELL_EFFECT_LIMIT { + return Err(profile_limit(format!( + "spell {wrapper_spell} has more than {PROFILE_SPELL_EFFECT_LIMIT} effects" + ))); + } + Ok(learn_target_from_effects(wrapper_spell, &effects)) +} + +#[derive(Default)] +struct ProfileOfferingScan { + examined: usize, + lowest_required_level: Option, +} + +impl ProfileOfferingScan { + fn inspect( + &mut self, + ctx: &ReducerContext, + class: u8, + level: u32, + offering: &TrainerSpell, + ) -> bool { + self.examined += 1; + if offering.learn_skill_line != 0 { + return false; + } + let Some(trainer) = ctx + .db + .game_creature_template() + .entry() + .find(offering.trainer_entry) + else { + return false; + }; + if !lyracore_shared::trainer::serves(class, trainer.trainer_type, trainer.trainer_class) { + return false; + } + let required_level = u32::from(offering.required_level); + if level >= required_level { + return true; + } + self.lowest_required_level = Some( + self.lowest_required_level + .map_or(required_level, |seen| seen.min(required_level)), + ); + false + } + + fn refusal(&self, requested_spell: u32, class: u8) -> crate::actor::ActionRefusal { + if let Some(required_level) = self.lowest_required_level { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Level, + format!("spell {requested_spell} requires level {required_level}"), + ) + } else { + crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::Class, + format!("spell {requested_spell} is not offered for class {class}"), + ) + } + } +} + +fn sorted_offerings( + ctx: &ReducerContext, + spell_id: u32, + remaining: usize, +) -> (Vec, bool) { + let mut offerings: Vec<_> = ctx + .db + .game_trainer_spell() + .by_spell() + .filter(&spell_id) + .take(remaining + 1) + .collect(); + offerings.sort_by_key(|offering| offering.id); + let overflow = offerings.len() > remaining; + offerings.truncate(remaining); + (offerings, overflow) +} + +/// Admit one imported trainer spell without scanning the catalogue. One request examines at most 16 +/// class-spell offerings across the requested spell and its wrappers and reads one overflow row when +/// needed. If the requested id resolves to another target, its rejected direct lookup reads at most +/// 17 rows before the wrapper search reads at most 17 more. Reverse lookup reads at most 17 effects. +/// Target resolution reads at most four effects for the requested spell and each of at most 16 +/// candidate wrappers. Every examined class-spell offering reads one exact trainer template. A usable +/// offering ends the search; `ProfileLimit` means the bounded prefix was inconclusive. +fn admit_profile_spell( + ctx: &ReducerContext, + spell_id: u32, + class: u8, + level: u32, +) -> Result<(), crate::actor::ActionRefusal> { + let mut scan = ProfileOfferingScan::default(); + let (direct, direct_overflow) = sorted_offerings(ctx, spell_id, PROFILE_TRAINER_OFFERING_LIMIT); + let direct_matches = if direct.is_empty() { + false + } else { + profile_learn_target(ctx, spell_id)? == spell_id + }; + if direct_matches { + for offering in &direct { + if scan.inspect(ctx, class, level, offering) { + return Ok(()); + } + } + if direct_overflow { + return Err(profile_limit(format!( + "spell {spell_id} trainer admission exceeded {PROFILE_TRAINER_OFFERING_LIMIT} offerings" + ))); + } + } + + let mut trigger_effects: Vec<_> = ctx + .db + .game_spell_effect() + .by_trigger_spell() + .filter(&spell_id) + .take(PROFILE_WRAPPER_EFFECT_LIMIT + 1) + .collect(); + trigger_effects.sort_by_key(|effect| (effect.spell_id, effect.effect_index, effect.id)); + let trigger_overflow = trigger_effects.len() > PROFILE_WRAPPER_EFFECT_LIMIT; + trigger_effects.truncate(PROFILE_WRAPPER_EFFECT_LIMIT); + let mut wrappers: Vec<_> = trigger_effects + .iter() + .filter(|effect| learn_target_trigger(effect) == Some(spell_id)) + .map(|effect| effect.spell_id) + .filter(|wrapper| *wrapper != spell_id) + .collect(); + wrappers.sort_unstable(); + wrappers.dedup(); + + for wrapper in wrappers { + if profile_learn_target(ctx, wrapper)? != spell_id { + continue; + } + let remaining = PROFILE_TRAINER_OFFERING_LIMIT.saturating_sub(scan.examined); + if remaining == 0 { + return Err(profile_limit(format!( + "spell {spell_id} trainer admission exceeded {PROFILE_TRAINER_OFFERING_LIMIT} offerings" + ))); + } + let (wrapper_offerings, offering_overflow) = sorted_offerings(ctx, wrapper, remaining); + for offering in &wrapper_offerings { + if scan.inspect(ctx, class, level, offering) { + return Ok(()); + } + } + if offering_overflow { + return Err(profile_limit(format!( + "spell {spell_id} trainer admission exceeded {PROFILE_TRAINER_OFFERING_LIMIT} offerings" + ))); + } + } + if trigger_overflow { + return Err(profile_limit(format!( + "spell {spell_id} trainer admission exceeded {PROFILE_WRAPPER_EFFECT_LIMIT} reverse trigger effects" + ))); + } + Err(scan.refusal(spell_id, class)) } fn demo_profile_spell_admitted(class: u8, spell_id: u32) -> bool { @@ -216,47 +417,7 @@ pub(crate) fn reconcile_profile_spell( )); } } else { - let offerings: Vec<_> = ctx - .db - .game_trainer_spell() - .iter() - .filter(|offering| { - offering.learn_skill_line == 0 - && resolve_learn_target(ctx, offering.spell_id) == spell_id - && ctx - .db - .game_creature_template() - .entry() - .find(offering.trainer_entry) - .is_some_and(|trainer| { - lyracore_shared::trainer::serves( - actor.class(), - trainer.trainer_type, - trainer.trainer_class, - ) - }) - }) - .collect(); - if offerings.is_empty() { - return Err(crate::actor::ActionRefusal::new( - crate::actor::ActionRefusalKind::Class, - format!( - "spell {spell_id} is not offered for class {}", - actor.class() - ), - )); - } - let required_level = offerings - .iter() - .map(|offering| u32::from(offering.required_level)) - .min() - .unwrap_or(0); - if actor.level < required_level { - return Err(crate::actor::ActionRefusal::new( - crate::actor::ActionRefusalKind::Level, - format!("spell {spell_id} requires level {required_level}"), - )); - } + admit_profile_spell(ctx, spell_id, actor.class(), actor.level)?; } if let Some(chain) = ctx.db.game_spell_chain().spell_id().find(spell_id) { if chain.prev_spell != 0 && !crate::spell::knows_spell(ctx, guid, chain.prev_spell) { diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index c484471e0..5541849ff 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -189,7 +189,13 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { node.assert_sql(&format!( "DELETE FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" )); + let existing_food = one(&node, "SELECT * FROM game_item_template WHERE entry = 117"); node.assert_call("playerbots_fixture_provision_catalog", &[]); + assert_eq!( + one(&node, "SELECT * FROM game_item_template WHERE entry = 117"), + existing_food, + "fixture catalogue must preserve an existing low-ID template" + ); node.assert_call("playerbots_fixture_provision_complete_profile", &[&guid]); select(&node, &guid, "cohort"); node.assert_call("playerbots_fixture_runner_due", &[]); @@ -316,6 +322,123 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { ); } +fn provision_spell_outcome(node: &Standalone, guid: &str, spell: &str) -> String { + node.assert_call("playerbots_fixture_provision_reset", &[guid]); + node.assert_call("playerbots_fixture_provision_spell_action", &[guid, spell]); + node.assert_call("playerbots_fixture_provision_steps", &[guid, "1"]); + one( + node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase() +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_bounds_imported_style_trainer_facts() { + let (node, guid) = fixture("playerbots-provisioning-trainers", "1", "0"); + node.assert_call("debug_set_level", &[&guid, "20"]); + node.assert_call("playerbots_fixture_provision_trainer_catalog", &[&guid]); + select(&node, &guid, "cohort"); + let unrelated_offerings = node + .query_rows("SELECT spell_id FROM game_trainer_spell") + .into_iter() + .filter(|row| { + let spell_id = row["spell_id"].parse::().unwrap(); + (5_099_000..5_099_040).contains(&spell_id) + }) + .count(); + assert_eq!(unrelated_offerings, 40); + let duplicate_offerings = node + .query_rows("SELECT id FROM game_trainer_spell WHERE spell_id = 5090210") + .len(); + let overflow_offerings = node + .query_rows("SELECT id FROM game_trainer_spell WHERE spell_id = 5090208") + .len(); + assert_eq!(duplicate_offerings, 17); + assert_eq!(overflow_offerings, 17); + + let direct = provision_spell_outcome(&node, &guid, "355"); + assert!(direct.contains("applied"), "{direct}"); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 355" + )) + .len(), + 1 + ); + + let wrapper = provision_spell_outcome(&node, &guid, "2050"); + assert!(wrapper.contains("applied"), "{wrapper}"); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 2050" + )) + .len(), + 1 + ); + + let duplicate_success = provision_spell_outcome(&node, &guid, "5090210"); + assert!(duplicate_success.contains("applied"), "{duplicate_success}"); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 5090210" + )) + .len(), + 1 + ); + + let wrong_wrapper = provision_spell_outcome(&node, &guid, "5090211"); + assert!(wrong_wrapper.contains("class"), "{wrong_wrapper}"); + + let channel_payload = provision_spell_outcome(&node, &guid, "139"); + assert!(channel_payload.contains("class"), "{channel_payload}"); + let proc_payload = provision_spell_outcome(&node, &guid, "133"); + assert!(proc_payload.contains("class"), "{proc_payload}"); + let wrong_class = provision_spell_outcome(&node, &guid, "5090205"); + assert!(wrong_class.contains("class"), "{wrong_class}"); + let low_level = provision_spell_outcome(&node, &guid, "5090206"); + assert!(low_level.contains("level"), "{low_level}"); + let previous_rank = provision_spell_outcome(&node, &guid, "5090207"); + assert!(previous_rank.contains("prerequisite"), "{previous_rank}"); + let overflow = provision_spell_outcome(&node, &guid, "5090208"); + assert!(overflow.contains("profilelimit"), "{overflow}"); + assert!(overflow.contains("stopped"), "{overflow}"); + + for spell in [ + 139, 133, 5_090_205, 5_090_206, 5_090_207, 5_090_208, 5_090_211, 5_090_212, + ] { + assert!( + node.query_rows(&format!( + "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = {spell}" + )) + .is_empty(), + "refused spell {spell} changed the spellbook" + ); + } + write_evidence( + &node, + "trainer-bounds", + serde_json::json!({ + "fixture_kind": "source-derived imported-style rows; not imported-world proof", + "unrelated_offerings": unrelated_offerings, + "duplicate_offerings": duplicate_offerings, + "overflow_offerings": overflow_offerings, + "direct": direct, + "wrapper": wrapper, + "duplicate_success": duplicate_success, + "wrong_wrapper": wrong_wrapper, + "channel_payload": channel_payload, + "proc_payload": proc_payload, + "wrong_class": wrong_class, + "low_level": low_level, + "previous_rank": previous_rank, + "overflow": overflow, + "spellbook": node.query_rows(&format!("SELECT spell_id FROM game_player_spell WHERE character_guid = {guid}")), + }), + ); +} + #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] fn playerbots_provisioning_yields_to_recovery_then_supplies_missing_recovery() { From 55acfef88033cce7a24d951fe6985cddcd5152f8 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:15:42 +0000 Subject: [PATCH 05/19] test(playerbots): prove low-id template preservation --- module/tests/playerbots_provisioning.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 5541849ff..38d4fa2fc 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -189,10 +189,10 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { node.assert_sql(&format!( "DELETE FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" )); - let existing_food = one(&node, "SELECT * FROM game_item_template WHERE entry = 117"); + let existing_food = one(&node, "SELECT * FROM game_item_template WHERE entry = 52"); node.assert_call("playerbots_fixture_provision_catalog", &[]); assert_eq!( - one(&node, "SELECT * FROM game_item_template WHERE entry = 117"), + one(&node, "SELECT * FROM game_item_template WHERE entry = 52"), existing_food, "fixture catalogue must preserve an existing low-ID template" ); From e9d747900140517719744f9020b573306afdd879 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:25:14 +0000 Subject: [PATCH 06/19] fix(playerbots): bound partial talent catalogs --- module/src/talent.rs | 21 ++++++++++--- module/tests/playerbots_provisioning.rs | 40 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/module/src/talent.rs b/module/src/talent.rs index fba0d285e..ced381ca2 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -437,8 +437,9 @@ const PROFILE_TALENT_TAB_LIMIT: usize = 16; /// Select the next talent in a profile-owned preferred tree through the same admission calculation /// as `do_learn_talent`. The indexed reads accept at most 64 learned rows, 16 tabs at the requested -/// tree position, and 64 talent candidates, with one extra row per set for overflow detection. One -/// exact talent-definition read per learned row computes the shared tree-point total. Admission then +/// tree position, and 64 rows from either the selected tab or the raw no-tab tree, with one extra row +/// per set for overflow detection. The raw tree limit applies before demo-row filtering. One exact +/// talent-definition read per learned row computes the shared tree-point total. Admission then /// performs one exact tab read and at most one exact required-spell read per candidate. pub(crate) fn select_profile_talent( ctx: &ReducerContext, @@ -494,12 +495,24 @@ pub(crate) fn select_profile_talent( .take(PROFILE_TALENT_LIMIT + 1) .collect() } else if entity.class() == 1 { - ctx.db + let raw_tree: Vec<_> = ctx + .db .game_talent() .by_tree() .filter(&preferred_tree) - .filter(|talent| talent.tab_id == 0) .take(PROFILE_TALENT_LIMIT + 1) + .collect(); + if raw_tree.len() > PROFILE_TALENT_LIMIT { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!( + "talent tree {preferred_tree} has more than {PROFILE_TALENT_LIMIT} raw rows" + ), + )); + } + raw_tree + .into_iter() + .filter(|talent| talent.tab_id == 0) .collect() } else { vec![] diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 38d4fa2fc..7e24e2e99 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -189,11 +189,11 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { node.assert_sql(&format!( "DELETE FROM game_item_instance WHERE owner_guid = {guid} AND slot = 15" )); - let existing_food = one(&node, "SELECT * FROM game_item_template WHERE entry = 52"); + let existing_template = one(&node, "SELECT * FROM game_item_template WHERE entry = 52"); node.assert_call("playerbots_fixture_provision_catalog", &[]); assert_eq!( one(&node, "SELECT * FROM game_item_template WHERE entry = 52"), - existing_food, + existing_template, "fixture catalogue must preserve an existing low-ID template" ); node.assert_call("playerbots_fixture_provision_complete_profile", &[&guid]); @@ -735,6 +735,42 @@ fn playerbots_talent_gates_are_atomic_and_use_global_tab_identity() { ); } +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_provisioning_stops_on_partial_talent_catalog_without_character_changes() { + let (node, guid) = fixture("playerbots-provisioning-partial-talents", "1", "0"); + node.assert_call("debug_set_level", &[&guid, "20"]); + assert!(node.query_rows("SELECT * FROM game_talent_tab").is_empty()); + node.assert_call("playerbots_fixture_provision_partial_talent_catalog", &[]); + let raw_tree_rows = node + .query_rows("SELECT talent_id FROM game_talent WHERE tree_id = 2") + .len(); + assert!(raw_tree_rows > 64); + select(&node, &guid, "cohort"); + let before = talent_state(&node, &guid); + + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let provisioning = one( + &node, + &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + ); + let history = provisioning["history"].to_ascii_lowercase(); + assert!(history.contains("stopped"), "{history}"); + assert!(history.contains("profilelimit"), "{history}"); + assert!(history.contains("raw rows"), "{history}"); + assert_eq!(talent_state(&node, &guid), before); + write_evidence( + &node, + "partial-talent-catalog", + serde_json::json!({ + "fixture_kind": "source-derived partial Talent import", + "raw_tree_rows": raw_tree_rows, + "provisioning": provisioning, + "character_talent_state": before, + }), + ); +} + #[test] #[ignore = "requires the integrated PB-002 Wasm, SpacetimeDB, and the playerbots Package"] fn playerbots_provisioning_migrates_populated_pb002_runner_state() { From 6104c0363e7f771ef585fb0b1e7018d27b801679 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:35:35 +0000 Subject: [PATCH 07/19] test(playerbots): serialize recovery runner steps --- module/tests/playerbots_provisioning.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 7e24e2e99..5397413cf 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -452,8 +452,7 @@ fn playerbots_provisioning_yields_to_recovery_then_supplies_missing_recovery() { node.assert_call("playerbots_fixture_prepare", &[]); node.assert_call("playerbots_fixture_runner_stage", &[&guid, "true"]); node.assert_call("playerbots_fixture_position", &[&guid, "1240"]); - node.assert_call("playerbots_fixture_runner_due", &[]); - node.assert_call("playerbots_fixture_runner_pass", &[]); + node.assert_call("playerbots_fixture_runner_pass_once", &[&guid]); let urgent = one( &node, &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), @@ -470,9 +469,20 @@ fn playerbots_provisioning_yields_to_recovery_then_supplies_missing_recovery() { node.assert_call("playerbots_fixture_cancel", &[&guid, "false"]); node.assert_call("playerbots_fixture_provision_remove_recovery", &[&guid]); - assert_eq!(item_count(&node, &guid, 117), 9); + let staged_food = item_count(&node, &guid, 117); let wounded_health = health(&node, &guid); - node.assert_call("playerbots_fixture_runner_pass", &[]); + write_evidence( + &node, + "priority-staged", + serde_json::json!({ + "food": staged_food, + "health": wounded_health, + "bot": one(&node, &format!("SELECT next_think_micros FROM pkg_playerbots_bot WHERE character_guid = {guid}")), + "provisioning": one(&node, &format!("SELECT * FROM pkg_playerbots_provisioning WHERE character_guid = {guid}")), + }), + ); + assert_eq!(staged_food, 9); + node.assert_call("playerbots_fixture_runner_pass_once", &[&guid]); assert_eq!(item_count(&node, &guid, 117), 10); let supplied = one( &node, @@ -482,7 +492,7 @@ fn playerbots_provisioning_yields_to_recovery_then_supplies_missing_recovery() { .to_ascii_lowercase() .contains("provisioning")); node.assert_call("playerbots_fixture_provision_due", &[&guid]); - node.assert_call("playerbots_fixture_runner_pass", &[]); + node.assert_call("playerbots_fixture_runner_pass_once", &[&guid]); assert_eq!(item_count(&node, &guid, 117), 9); assert!(!node .query_rows(&format!( From fcc13b4179fed4bdb929c2710bd6898b27303683 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:04:37 +0000 Subject: [PATCH 08/19] fix(playerbots): mark package-only provisioning paths --- module/src/items/inventory.rs | 1 + module/src/items/ops.rs | 3 +++ module/src/skill.rs | 2 ++ module/src/talent.rs | 5 +++++ module/src/trainer.rs | 11 +++++++++++ 5 files changed, 22 insertions(+) diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 9faea2435..8afae1bc6 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -359,6 +359,7 @@ pub(crate) fn apply_equip_item( /// Equip a profile item only when its destination is empty or holds lower item-level gear. The /// existing equip operation still owns every slot, level, proficiency, skill, reputation, and bind /// Gate. Full bag slots and missing equipped templates preserve what the Character already wears. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn apply_equip_profile_upgrade( ctx: &ReducerContext, player_guid: u64, diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 9db2ead4e..3823545e7 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -215,6 +215,7 @@ pub(crate) fn request_grant_item( /// Top a profile-owned resource up to a fixed target. The target is capped at the largest /// supported ammunition stack so a malformed profile cannot turn periodic repair into an unlimited /// grant. Storage still owns capacity, uniqueness, and Random Property Gates. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn request_profile_item( ctx: &ReducerContext, player_guid: u64, @@ -238,8 +239,10 @@ pub(crate) fn request_profile_item( Ok(missing) } +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_ITEM_LIMIT: u32 = 200; +#[cfg_attr(not(has_packages), allow(dead_code))] fn profile_item_target_admitted(target_count: u32) -> Result<(), ActionRefusal> { if (1..=PROFILE_ITEM_LIMIT).contains(&target_count) { return Ok(()); diff --git a/module/src/skill.rs b/module/src/skill.rs index 5682a63d6..76ebdc266 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -601,6 +601,7 @@ pub(crate) fn raise_combat_caps(ctx: &ReducerContext, guid: u64, new_level: u32) } } +#[cfg_attr(not(has_packages), allow(dead_code))] fn profile_mask_admits(mask: u32, id: u8) -> bool { mask == 0 || (id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0) } @@ -608,6 +609,7 @@ fn profile_mask_admits(mask: u32, id: u8) -> bool { /// Reconcile one combat skill selected by a free provisioning profile. Imported availability owns /// the race, class, and level Gates. A seed-only Shard falls back to the established class weapon /// table so the no-import demo remains usable. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn reconcile_profile_skill( ctx: &ReducerContext, guid: u64, diff --git a/module/src/talent.rs b/module/src/talent.rs index ced381ca2..7c1dfcdb3 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -221,6 +221,7 @@ pub fn prereq_satisfied( // =========================================================================================== /// `character_guid`'s learned rank in `talent_id` (0 if unlearned). Scans the character's small talent set. +#[cfg_attr(not(has_packages), allow(dead_code))] fn learned_rank(ctx: &ReducerContext, guid: u64, talent_id: u32) -> u8 { ctx.db .game_character_talent() @@ -412,6 +413,7 @@ pub(crate) fn do_learn_talent( /// Learn one profile-selected talent through the normal talent Gates. The Actor's durable owner is /// derived here so a Package cannot stamp learned state for another identity. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn reconcile_profile_talent( ctx: &ReducerContext, guid: u64, @@ -432,7 +434,9 @@ pub(crate) fn reconcile_profile_talent( Ok(after != before) } +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_TALENT_LIMIT: usize = 64; +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_TALENT_TAB_LIMIT: usize = 16; /// Select the next talent in a profile-owned preferred tree through the same admission calculation @@ -441,6 +445,7 @@ const PROFILE_TALENT_TAB_LIMIT: usize = 16; /// per set for overflow detection. The raw tree limit applies before demo-row filtering. One exact /// talent-definition read per learned row computes the shared tree-point total. Admission then /// performs one exact tab read and at most one exact required-spell read per candidate. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn select_profile_talent( ctx: &ReducerContext, guid: u64, diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 0aff44f6a..c7adfb6e6 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -178,14 +178,19 @@ pub(crate) fn resolve_learn_target(ctx: &ReducerContext, spell_id: u32) -> u32 { learn_target_from_effects(spell_id, &effects) } +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_TRAINER_OFFERING_LIMIT: usize = 16; +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_WRAPPER_EFFECT_LIMIT: usize = 16; +#[cfg_attr(not(has_packages), allow(dead_code))] const PROFILE_SPELL_EFFECT_LIMIT: usize = 3; +#[cfg_attr(not(has_packages), allow(dead_code))] fn profile_limit(detail: impl Into) -> crate::actor::ActionRefusal { crate::actor::ActionRefusal::new(crate::actor::ActionRefusalKind::ProfileLimit, detail.into()) } +#[cfg_attr(not(has_packages), allow(dead_code))] fn profile_learn_target( ctx: &ReducerContext, wrapper_spell: u32, @@ -206,11 +211,13 @@ fn profile_learn_target( } #[derive(Default)] +#[cfg_attr(not(has_packages), allow(dead_code))] struct ProfileOfferingScan { examined: usize, lowest_required_level: Option, } +#[cfg_attr(not(has_packages), allow(dead_code))] impl ProfileOfferingScan { fn inspect( &mut self, @@ -260,6 +267,7 @@ impl ProfileOfferingScan { } } +#[cfg_attr(not(has_packages), allow(dead_code))] fn sorted_offerings( ctx: &ReducerContext, spell_id: u32, @@ -285,6 +293,7 @@ fn sorted_offerings( /// Target resolution reads at most four effects for the requested spell and each of at most 16 /// candidate wrappers. Every examined class-spell offering reads one exact trainer template. A usable /// offering ends the search; `ProfileLimit` means the bounded prefix was inconclusive. +#[cfg_attr(not(has_packages), allow(dead_code))] fn admit_profile_spell( ctx: &ReducerContext, spell_id: u32, @@ -360,6 +369,7 @@ fn admit_profile_spell( Err(scan.refusal(spell_id, class)) } +#[cfg_attr(not(has_packages), allow(dead_code))] fn demo_profile_spell_admitted(class: u8, spell_id: u32) -> bool { match class { 1 => matches!(spell_id, 355 | 7386), @@ -374,6 +384,7 @@ fn demo_profile_spell_admitted(class: u8, spell_id: u32) -> bool { /// real offering from a trainer that serves the Character's class, then apply the offering's level /// and the spell rank-chain Gates. A seed-only Shard has no trainer catalogue, so its supported /// demo profiles may learn a seeded spell at the spell's own level. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn reconcile_profile_spell( ctx: &ReducerContext, guid: u64, From 2f30691ffbb6f862b4350d3b6192619d3799ba51 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:04:37 +0000 Subject: [PATCH 09/19] chore(gateway): refresh provisioning index bindings --- gateway/src/stdb/bindings/spell_effect_type.rs | 2 ++ gateway/src/stdb/bindings/talent_tab_type.rs | 2 ++ gateway/src/stdb/bindings/talent_type.rs | 2 ++ gateway/src/stdb/bindings/trainer_spell_type.rs | 2 ++ 4 files changed, 8 insertions(+) diff --git a/gateway/src/stdb/bindings/spell_effect_type.rs b/gateway/src/stdb/bindings/spell_effect_type.rs index bc15e463d..b37e16413 100644 --- a/gateway/src/stdb/bindings/spell_effect_type.rs +++ b/gateway/src/stdb/bindings/spell_effect_type.rs @@ -87,6 +87,7 @@ impl __sdk::__query_builder::HasCols for SpellEffect { pub struct SpellEffectIxCols { pub id: __sdk::__query_builder::IxCol, pub spell_id: __sdk::__query_builder::IxCol, + pub trigger_spell: __sdk::__query_builder::IxCol, } impl __sdk::__query_builder::HasIxCols for SpellEffect { @@ -95,6 +96,7 @@ impl __sdk::__query_builder::HasIxCols for SpellEffect { SpellEffectIxCols { id: __sdk::__query_builder::IxCol::new(table_name, "id"), spell_id: __sdk::__query_builder::IxCol::new(table_name, "spell_id"), + trigger_spell: __sdk::__query_builder::IxCol::new(table_name, "trigger_spell"), } } } diff --git a/gateway/src/stdb/bindings/talent_tab_type.rs b/gateway/src/stdb/bindings/talent_tab_type.rs index d5f48028b..5a606ab52 100644 --- a/gateway/src/stdb/bindings/talent_tab_type.rs +++ b/gateway/src/stdb/bindings/talent_tab_type.rs @@ -46,6 +46,7 @@ impl __sdk::__query_builder::HasCols for TalentTab { /// /// Provides typed access to indexed columns for query building. pub struct TalentTabIxCols { + pub order_index: __sdk::__query_builder::IxCol, pub tab_id: __sdk::__query_builder::IxCol, } @@ -53,6 +54,7 @@ impl __sdk::__query_builder::HasIxCols for TalentTab { type IxCols = TalentTabIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { TalentTabIxCols { + order_index: __sdk::__query_builder::IxCol::new(table_name, "order_index"), tab_id: __sdk::__query_builder::IxCol::new(table_name, "tab_id"), } } diff --git a/gateway/src/stdb/bindings/talent_type.rs b/gateway/src/stdb/bindings/talent_type.rs index 83ecc07b3..e6c4da206 100644 --- a/gateway/src/stdb/bindings/talent_type.rs +++ b/gateway/src/stdb/bindings/talent_type.rs @@ -88,6 +88,7 @@ impl __sdk::__query_builder::HasCols for Talent { /// /// Provides typed access to indexed columns for query building. pub struct TalentIxCols { + pub tab_id: __sdk::__query_builder::IxCol, pub talent_id: __sdk::__query_builder::IxCol, pub tree_id: __sdk::__query_builder::IxCol, } @@ -96,6 +97,7 @@ impl __sdk::__query_builder::HasIxCols for Talent { type IxCols = TalentIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { TalentIxCols { + tab_id: __sdk::__query_builder::IxCol::new(table_name, "tab_id"), talent_id: __sdk::__query_builder::IxCol::new(table_name, "talent_id"), tree_id: __sdk::__query_builder::IxCol::new(table_name, "tree_id"), } diff --git a/gateway/src/stdb/bindings/trainer_spell_type.rs b/gateway/src/stdb/bindings/trainer_spell_type.rs index caa17e516..e279fc358 100644 --- a/gateway/src/stdb/bindings/trainer_spell_type.rs +++ b/gateway/src/stdb/bindings/trainer_spell_type.rs @@ -53,6 +53,7 @@ impl __sdk::__query_builder::HasCols for TrainerSpell { /// Provides typed access to indexed columns for query building. pub struct TrainerSpellIxCols { pub id: __sdk::__query_builder::IxCol, + pub spell_id: __sdk::__query_builder::IxCol, pub trainer_entry: __sdk::__query_builder::IxCol, } @@ -61,6 +62,7 @@ impl __sdk::__query_builder::HasIxCols for TrainerSpell { fn ix_cols(table_name: &'static str) -> Self::IxCols { TrainerSpellIxCols { id: __sdk::__query_builder::IxCol::new(table_name, "id"), + spell_id: __sdk::__query_builder::IxCol::new(table_name, "spell_id"), trainer_entry: __sdk::__query_builder::IxCol::new(table_name, "trainer_entry"), } } From a909434da6af3bbb8ca4509f4e1fd16ec689dc1f Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:24:16 +0000 Subject: [PATCH 10/19] fix(playerbots): bound skill repair and reject bank gear --- module/src/items/inventory.rs | 2 +- module/src/skill.rs | 69 +++++++++++++++++-------- module/tests/playerbots_provisioning.rs | 67 ++++++++++++++++++++---- 3 files changed, 105 insertions(+), 33 deletions(-) diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 8afae1bc6..6bcbbe4c1 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -365,7 +365,7 @@ pub(crate) fn apply_equip_profile_upgrade( player_guid: u64, from_slot: u8, ) -> Result { - if from_slot <= BAG_SLOT_END_INCL { + if from_slot <= BAG_SLOT_END_INCL || !is_carried_slot(from_slot) { return Err(refuse( ItemRefusal::WrongSlot, "profile equipment must come from carried storage", diff --git a/module/src/skill.rs b/module/src/skill.rs index 76ebdc266..8572168d8 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -606,9 +606,14 @@ fn profile_mask_admits(mask: u32, id: u8) -> bool { mask == 0 || (id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0) } +#[cfg_attr(not(has_packages), allow(dead_code))] +const PROFILE_SKILL_AVAILABILITY_LIMIT: usize = 16; + /// Reconcile one combat skill selected by a free provisioning profile. Imported availability owns /// the race, class, and level Gates. A seed-only Shard falls back to the established class weapon -/// table so the no-import demo remains usable. +/// table so the no-import demo remains usable. The indexed admission reads at most 16 matching +/// availability rows plus one overflow row. An eligible row ends the read early; an inconclusive +/// bounded prefix returns `ProfileLimit`. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn reconcile_profile_skill( ctx: &ReducerContext, @@ -627,29 +632,51 @@ pub(crate) fn reconcile_profile_skill( format!("learner {guid} not in world"), ) })?; - let availability: Vec<_> = ctx - .db - .game_skill_availability() - .by_skill_line() - .filter(&line) - .collect(); let imported = ctx.db.game_skill_availability().count() > 0; - let identity_admitted = availability.iter().any(|row| { - profile_mask_admits(row.race_mask, actor.race()) - && profile_mask_admits(row.class_mask, actor.class()) - }); - let admitted = if imported { - availability.iter().any(|row| { - profile_mask_admits(row.race_mask, actor.race()) - && profile_mask_admits(row.class_mask, actor.class()) - && i64::from(row.min_level) <= i64::from(actor.level) - }) + let (admitted, identity_admitted, overflow) = if imported { + let mut admitted = false; + let mut identity_admitted = false; + let mut examined = 0usize; + let mut overflow = false; + for row in ctx + .db + .game_skill_availability() + .by_skill_line() + .filter(&line) + .take(PROFILE_SKILL_AVAILABILITY_LIMIT + 1) + { + if examined == PROFILE_SKILL_AVAILABILITY_LIMIT { + overflow = true; + break; + } + examined += 1; + let identity_matches = profile_mask_admits(row.race_mask, actor.race()) + && profile_mask_admits(row.class_mask, actor.class()); + identity_admitted |= identity_matches; + if identity_matches && i64::from(row.min_level) <= i64::from(actor.level) { + admitted = true; + break; + } + } + (admitted, identity_admitted, overflow) } else { - line == skill_line::DEFENSE - || line == skill_line::UNARMED - || class_weapon_skill_lines(actor.class()).contains(&line) + ( + line == skill_line::DEFENSE + || line == skill_line::UNARMED + || class_weapon_skill_lines(actor.class()).contains(&line), + false, + false, + ) }; if !admitted { + if overflow { + return Err(crate::actor::ActionRefusal::new( + crate::actor::ActionRefusalKind::ProfileLimit, + format!( + "skill line {line} admission exceeded {PROFILE_SKILL_AVAILABILITY_LIMIT} availability rows" + ), + )); + } let kind = if imported && identity_admitted { crate::actor::ActionRefusalKind::Level } else { @@ -684,7 +711,7 @@ pub(crate) fn reconcile_profile_skill( character_guid: guid, owner_identity: actor.owner_identity, skill_line: line, - current: 1, + current: cap, max_rank: cap, }); } diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 5397413cf..fad6d3773 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -222,6 +222,9 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { ); let history = completed["history"].to_ascii_lowercase(); let provisioned = gameplay(&node, &guid); + let skill_availability = node.query_rows( + "SELECT id, race_mask, class_mask, min_level FROM game_skill_availability WHERE skill_line = 43", + ); write_evidence( &node, "profile", @@ -232,8 +235,10 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { "provisioning": completed, "gameplay": provisioned, "fixture_profile_spells": node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0"), + "skill_availability": skill_availability, }), ); + assert!(skill_availability.len() > 16); assert_eq!(item_count(&node, &guid, 4496), 4); assert_eq!(item_count(&node, &guid, 117), 10); assert_eq!(item_count(&node, &guid, 118), 5); @@ -249,13 +254,14 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { )["entry"], "25" ); - assert_eq!( - node.query_rows(&format!( - "SELECT * FROM game_player_skill WHERE character_guid = {guid} AND skill_line = 43" - )) - .len(), - 1 + let profile_skill = one( + &node, + &format!( + "SELECT current, max_rank FROM game_player_skill WHERE character_guid = {guid} AND skill_line = 43" + ), ); + assert_eq!(profile_skill["current"], "100"); + assert_eq!(profile_skill["max_rank"], "100"); assert!(!node .query_rows(&format!( "SELECT * FROM game_character_talent WHERE character_guid = {guid}" @@ -599,6 +605,24 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { "SELECT * FROM game_player_spell WHERE character_guid = {guid} AND spell_id = 133" )) .is_empty()); + node.assert_call("playerbots_fixture_provision_skill_overflow", &[&guid]); + node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); + let skill_overflow = one( + &node, + &format!("SELECT history FROM pkg_playerbots_provisioning WHERE character_guid = {guid}"), + )["history"] + .to_ascii_lowercase(); + let overflow_skills = node.query_rows(&format!( + "SELECT * FROM game_player_skill WHERE character_guid = {guid} AND skill_line = 43" + )); + write_evidence( + &node, + "skill-overflow", + serde_json::json!({"history": skill_overflow, "skills": overflow_skills}), + ); + assert!(skill_overflow.contains("refused")); + assert!(skill_overflow.contains("profilelimit")); + assert!(overflow_skills.is_empty()); node.assert_call("playerbots_fixture_provision_profile_overflow", &[&guid]); node.assert_call("playerbots_fixture_provision_steps", &[&guid, "1"]); let overflow = one( @@ -632,15 +656,36 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { ), ); assert_eq!(mainhand["entry"], "50"); - write_evidence( - &node, - "stops", - serde_json::json!({"stopped": stopped, "missing_item": missing, "missing_spell": missing_spell, "wrong_class": wrong_class, "overflow": overflow, "dead": dead, "gameplay": gameplay(&node, &guid)}), + gear_node.assert_call("playerbots_fixture_provision_bank_weapon", &[&gear_guid]); + let bank_before = gear_node.query_rows(&format!( + "SELECT guid, entry, slot FROM game_item_instance WHERE owner_guid = {gear_guid} AND slot = 39" + )); + let refused_bank_equip = gear_node.call( + "playerbots_fixture_provision_equip_bank_weapon", + &[&gear_guid], ); + let bank_call_succeeded = refused_bank_equip.status.success(); + let bank_refusal = format!( + "{}{}", + String::from_utf8_lossy(&refused_bank_equip.stdout), + String::from_utf8_lossy(&refused_bank_equip.stderr) + ) + .to_ascii_lowercase(); + let bank_after = gear_node.query_rows(&format!( + "SELECT guid, entry, slot FROM game_item_instance WHERE owner_guid = {gear_guid} AND slot = 39" + )); write_evidence( &gear_node, "gear", - serde_json::json!({"mainhand": mainhand, "gameplay": gameplay(&gear_node, &gear_guid)}), + serde_json::json!({"mainhand": mainhand, "bank_item": bank_after, "bank_call_succeeded": bank_call_succeeded, "bank_refusal": bank_refusal, "gameplay": gameplay(&gear_node, &gear_guid)}), + ); + assert!(!bank_call_succeeded); + assert!(bank_refusal.contains("wrongslot"), "{bank_refusal}"); + assert_eq!(bank_after, bank_before); + write_evidence( + &node, + "stops", + serde_json::json!({"stopped": stopped, "missing_item": missing, "missing_spell": missing_spell, "wrong_class": wrong_class, "skill_overflow": skill_overflow, "overflow": overflow, "dead": dead, "gameplay": gameplay(&node, &guid)}), ); } From 474ccd4d55220ff3e255ed2bfcfce32237fdbcc3 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:27:37 +0000 Subject: [PATCH 11/19] fix(playerbots): enumerate bounded skill rows --- module/src/skill.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/module/src/skill.rs b/module/src/skill.rs index 8572168d8..fdba95600 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -636,20 +636,19 @@ pub(crate) fn reconcile_profile_skill( let (admitted, identity_admitted, overflow) = if imported { let mut admitted = false; let mut identity_admitted = false; - let mut examined = 0usize; let mut overflow = false; - for row in ctx + for (examined, row) in ctx .db .game_skill_availability() .by_skill_line() .filter(&line) .take(PROFILE_SKILL_AVAILABILITY_LIMIT + 1) + .enumerate() { if examined == PROFILE_SKILL_AVAILABILITY_LIMIT { overflow = true; break; } - examined += 1; let identity_matches = profile_mask_admits(row.race_mask, actor.race()) && profile_mask_admits(row.class_mask, actor.class()); identity_admitted |= identity_matches; From f89918b6cdaf157322676aac1be4f19b874d1533 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:31:34 +0000 Subject: [PATCH 12/19] test(playerbots): preserve imported skill availability --- module/tests/playerbots_provisioning.rs | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index fad6d3773..d20e24852 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -196,7 +196,41 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { existing_template, "fixture catalogue must preserve an existing low-ID template" ); + node.assert_sql("INSERT INTO game_skill_availability (id,skill_line,race_mask,class_mask,flags,min_level) VALUES (5096999,44,0,1,0,1)"); + let existing_availability = one( + &node, + "SELECT * FROM game_skill_availability WHERE id = 5096999", + ); node.assert_call("playerbots_fixture_provision_complete_profile", &[&guid]); + assert_eq!( + one( + &node, + "SELECT * FROM game_skill_availability WHERE id = 5096999" + ), + existing_availability, + "fixture skill staging must preserve unrelated availability rows" + ); + node.assert_sql("INSERT INTO game_skill_availability (id,skill_line,race_mask,class_mask,flags,min_level) VALUES (5096998,43,0,128,0,1)"); + let skill_rows_before_refusal = + node.query_rows("SELECT * FROM game_skill_availability WHERE skill_line = 43"); + let refused_skill_staging = + node.call("playerbots_fixture_provision_complete_profile", &[&guid]); + let skill_staging_refusal = format!( + "{}{}", + String::from_utf8_lossy(&refused_skill_staging.stdout), + String::from_utf8_lossy(&refused_skill_staging.stderr) + ) + .to_ascii_lowercase(); + assert!(!refused_skill_staging.status.success()); + assert!( + skill_staging_refusal.contains("non-fixture availability"), + "{skill_staging_refusal}" + ); + assert_eq!( + node.query_rows("SELECT * FROM game_skill_availability WHERE skill_line = 43"), + skill_rows_before_refusal + ); + node.assert_sql("DELETE FROM game_skill_availability WHERE id = 5096998"); select(&node, &guid, "cohort"); node.assert_call("playerbots_fixture_runner_due", &[]); node.assert_call("playerbots_fixture_runner_pass", &[]); @@ -236,6 +270,8 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { "gameplay": provisioned, "fixture_profile_spells": node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0"), "skill_availability": skill_availability, + "preserved_skill_availability": existing_availability, + "skill_staging_refusal": skill_staging_refusal, }), ); assert!(skill_availability.len() > 16); From 223fd164c2f2ffd34def79d3c951fa110d7d3c04 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:41:54 +0000 Subject: [PATCH 13/19] test(playerbots): assert the bounded skill stop --- module/tests/playerbots_provisioning.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index d20e24852..04318d6de 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -196,6 +196,7 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { existing_template, "fixture catalogue must preserve an existing low-ID template" ); + node.assert_sql("DELETE FROM game_skill_availability WHERE id = 5096999"); node.assert_sql("INSERT INTO game_skill_availability (id,skill_line,race_mask,class_mask,flags,min_level) VALUES (5096999,44,0,1,0,1)"); let existing_availability = one( &node, @@ -210,6 +211,7 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { existing_availability, "fixture skill staging must preserve unrelated availability rows" ); + node.assert_sql("DELETE FROM game_skill_availability WHERE id = 5096998"); node.assert_sql("INSERT INTO game_skill_availability (id,skill_line,race_mask,class_mask,flags,min_level) VALUES (5096998,43,0,128,0,1)"); let skill_rows_before_refusal = node.query_rows("SELECT * FROM game_skill_availability WHERE skill_line = 43"); @@ -656,7 +658,7 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { "skill-overflow", serde_json::json!({"history": skill_overflow, "skills": overflow_skills}), ); - assert!(skill_overflow.contains("refused")); + assert!(skill_overflow.contains("stopped")); assert!(skill_overflow.contains("profilelimit")); assert!(overflow_skills.is_empty()); node.assert_call("playerbots_fixture_provision_profile_overflow", &[&guid]); From 7e5e4df3f0630ffde00c091e72c79499c98570bb Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:49:43 +0000 Subject: [PATCH 14/19] test(playerbots): match the owning bank slot refusal --- module/tests/playerbots_provisioning.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 04318d6de..7f5d94619 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -718,7 +718,7 @@ fn playerbots_provisioning_stops_cleanly_and_preserves_owned_items_and_gear() { serde_json::json!({"mainhand": mainhand, "bank_item": bank_after, "bank_call_succeeded": bank_call_succeeded, "bank_refusal": bank_refusal, "gameplay": gameplay(&gear_node, &gear_guid)}), ); assert!(!bank_call_succeeded); - assert!(bank_refusal.contains("wrongslot"), "{bank_refusal}"); + assert!(bank_refusal.contains("item:wrong_slot"), "{bank_refusal}"); assert_eq!(bank_after, bank_before); write_evidence( &node, From d9e044d09afbd8a52ebb61ceeec8a4d368a82f1f Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:17:32 +0000 Subject: [PATCH 15/19] test(playerbots): capture the first provisioning pass atomically --- module/tests/playerbots_provisioning.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs index 7f5d94619..be9e6bb23 100644 --- a/module/tests/playerbots_provisioning.rs +++ b/module/tests/playerbots_provisioning.rs @@ -233,9 +233,8 @@ fn playerbots_provisioning_arms_then_reconciles_and_repairs_without_cost() { skill_rows_before_refusal ); node.assert_sql("DELETE FROM game_skill_availability WHERE id = 5096998"); - select(&node, &guid, "cohort"); - node.assert_call("playerbots_fixture_runner_due", &[]); - node.assert_call("playerbots_fixture_runner_pass", &[]); + node.assert_call("playerbots_fixture_runner_select_cohort", &[&guid]); + node.assert_call("playerbots_fixture_runner_pass_once", &[&guid]); let runner = one( &node, &format!("SELECT * FROM pkg_playerbots_runner WHERE character_guid = {guid}"), From 429b96f456e2e95730cc2d82d950c395e06dad8b Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:12:51 +0000 Subject: [PATCH 16/19] test(playerbots): allow bounded provisioning before follow --- module/tests/playerbots_companion.rs | 65 +++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 2ce535c9d..e511a492e 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -231,6 +231,56 @@ fn pass_once(node: &Standalone, guid: &str) { node.assert_call("playerbots_fixture_runner_pass_once", &[guid]); } +/// The largest supported Provisioning Profile has at most 30 actions. Two extra passes cover the +/// cycle boundary and the Follow selection. A later Follow would exceed the profile's bound. +fn resume_follow_after_provisioning( + node: &Standalone, + guid: &str, + objective_sequence: &str, + case: &str, +) -> BTreeMap { + const PASS_LIMIT: usize = 32; + + let starting_position = position(node, guid); + let role = node.query_rows(&format!( + "SELECT role FROM pkg_playerbots_bot WHERE character_guid = {guid}" + ))[0]["role"] + .clone(); + node.assert_call("playerbots_fixture_provision_due", &[guid]); + let mut saw_provisioning = false; + for pass in 1..=PASS_LIMIT { + pass_once(node, guid); + let state = runner(node, guid); + evidence(node, &format!("{case}-pass-{pass}")); + assert_eq!(state["objective_sequence"], objective_sequence); + assert!(state["objective"].contains("companion"), "{state:?}"); + assert_eq!( + node.query_rows(&format!( + "SELECT role FROM pkg_playerbots_bot WHERE character_guid = {guid}" + ))[0]["role"], + role + ); + if state["chosen"].contains("follow") { + assert!( + saw_provisioning, + "forced Provisioning never consumed a pass" + ); + assert!(state["foreground"].contains("movement"), "{state:?}"); + assert!(poll_until(POLL_TIMEOUT, || { + let current = position(node, guid); + (current.0 - starting_position.0).abs() > 0.01 + || (current.1 - starting_position.1).abs() > 0.01 + })); + return state; + } + assert!(state["chosen"].contains("provisioning"), "{state:?}"); + assert!(state["last_outcome"].contains("provisioning"), "{state:?}"); + assert!(state["foreground"].contains("none"), "{state:?}"); + saw_provisioning = true; + } + panic!("Follow did not resume within {PASS_LIMIT} Provisioning Profile passes") +} + fn evidence(node: &Standalone, case: &str) { let path = support::log_dir().join(format!("{}-{case}.json", node.shard_name())); let record = serde_json::json!({ @@ -756,11 +806,14 @@ fn playerbots_explicit_cancellation_releases_the_heal_and_resumes_follow() { .is_empty())); let objective = runner(&node, priest)["objective_sequence"].clone(); node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); + assert!(node + .query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )) + .is_empty()); node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); - due(&node, priest); - assert_eq!(runner(&node, priest)["objective_sequence"], objective); - assert!(runner(&node, priest)["chosen"].contains("follow")); - assert!(runner(&node, priest)["companion_heal_target_guid"].contains("none")); + let resumed = resume_follow_after_provisioning(&node, priest, &objective, "cancel-resume"); + assert!(resumed["companion_heal_target_guid"].contains("none")); evidence(&node, "cancel-resume"); } @@ -789,9 +842,7 @@ fn playerbots_completion_time_los_refusal_releases_the_heal_and_resumes_follow() assert!(runner(&node, priest)["last_outcome"].contains("refused")); assert!(runner(&node, priest)["companion_heal_target_guid"].contains("none")); node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); - due(&node, priest); - assert_eq!(runner(&node, priest)["objective_sequence"], objective); - assert!(runner(&node, priest)["chosen"].contains("follow")); + resume_follow_after_provisioning(&node, priest, &objective, "los-refusal-resume"); evidence(&node, "los-refusal-resume"); } From 864d71542b798a1ebd28e1a76e20eea47175ccd6 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:15:02 +0000 Subject: [PATCH 17/19] test(playerbots): make postcast provisioning deterministic --- module/tests/playerbots_companion.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index e511a492e..c4dbacedc 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -287,6 +287,7 @@ fn evidence(node: &Standalone, case: &str) { "case": case, "spacetimedb": "2.7.1", "runner": node.query_rows("SELECT * FROM pkg_playerbots_runner"), + "provisioning": node.query_rows("SELECT * FROM pkg_playerbots_provisioning"), "actions": node.query_rows("SELECT * FROM pkg_playerbots_action"), "entities": node.query_rows("SELECT guid, map_id, instance_id, x, y, z, health, max_health, dead FROM game_world_entity"), "party": node.query_rows("SELECT * FROM game_group_member"), @@ -796,9 +797,9 @@ fn playerbots_actor_channel_request_has_a_typed_unsupported_refusal() { fn playerbots_explicit_cancellation_releases_the_heal_and_resumes_follow() { let (node, bots) = fixture("playerbots-companion-cancel-resume"); let (priest, ally) = (&bots[0], &bots[2]); - select(&node, priest, "cohort"); node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); - due(&node, priest); + node.assert_call("playerbots_fixture_runner_select_cohort", &[priest]); + pass_once(&node, priest); assert!(poll_until(POLL_TIMEOUT, || !node .query_rows(&format!( "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" @@ -822,10 +823,10 @@ fn playerbots_explicit_cancellation_releases_the_heal_and_resumes_follow() { fn playerbots_completion_time_los_refusal_releases_the_heal_and_resumes_follow() { let (node, bots) = fixture("playerbots-companion-los-resume"); let (priest, ally) = (&bots[0], &bots[2]); - select(&node, priest, "cohort"); node.assert_call("debug_set_nav_enabled", &["true"]); node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); - due(&node, priest); + node.assert_call("playerbots_fixture_runner_select_cohort", &[priest]); + pass_once(&node, priest); assert!(poll_until(POLL_TIMEOUT, || !node .query_rows(&format!( "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" From 35f0e5247c2be7baf6c2bfed2788456b6acf84a3 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:19:31 +0000 Subject: [PATCH 18/19] test(playerbots): prove explicit companion cancellation --- module/tests/playerbots_companion.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index c4dbacedc..e36f34128 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -797,21 +797,30 @@ fn playerbots_actor_channel_request_has_a_typed_unsupported_refusal() { fn playerbots_explicit_cancellation_releases_the_heal_and_resumes_follow() { let (node, bots) = fixture("playerbots-companion-cancel-resume"); let (priest, ally) = (&bots[0], &bots[2]); + node.assert_sql("UPDATE game_spell SET cast_time_ms = 60000 WHERE spell_id = 5090100"); node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); node.assert_call("playerbots_fixture_runner_select_cohort", &[priest]); pass_once(&node, priest); - assert!(poll_until(POLL_TIMEOUT, || !node - .query_rows(&format!( - "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" - )) - .is_empty())); + let pending = node.query_rows(&format!( + "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" + )); + assert_eq!(pending.len(), 1); + let scheduled_id = pending[0]["scheduled_id"].clone(); let objective = runner(&node, priest)["objective_sequence"].clone(); node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); + evidence(&node, "cancelled-before-provisioning"); assert!(node .query_rows(&format!( "SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = {priest}" )) .is_empty()); + let cancelled = node.query_rows(&format!( + "SELECT cast_id, outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0]["cast_id"], scheduled_id); + assert_eq!(cancelled[0]["outcome"], "(cancelled = ())"); + assert!(runner(&node, priest)["last_outcome"].contains("cancelled")); node.assert_call("playerbots_fixture_companion_health", &[ally, "100"]); let resumed = resume_follow_after_provisioning(&node, priest, &objective, "cancel-resume"); assert!(resumed["companion_heal_target_guid"].contains("none")); From 58867b6f6fe411692b08ab2e0d67c7f090bf42ee Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:29:27 +0000 Subject: [PATCH 19/19] docs(actor): name profile talent entry point --- module/src/actor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/src/actor.rs b/module/src/actor.rs index c920b207a..b644df236 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -31,7 +31,7 @@ //! | `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback | //! | `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates | //! | `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent Gates | -//! | `learn_profile_talent` | `talent::do_learn_talent` | class/race/level/point/tier/prerequisite gates | +//! | `learn_profile_talent` | `talent::reconcile_profile_talent` | class/race/level/point/tier/prerequisite gates | //! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | //! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | //! | `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates |