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 b7ea7c180..7ea1564f3 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -102,6 +102,17 @@ 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 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 +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/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"), } } diff --git a/module/src/actor.rs b/module/src/actor.rs index 366630663..b644df236 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -28,6 +28,12 @@ //! | `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 | +//! | `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent 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 | //! | `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 +130,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 +141,12 @@ 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; + pub(crate) use crate::talent::select_profile_talent as select_profile_talent; +} // ---- NPC services / world ---- @@ -173,6 +187,11 @@ pub enum ActionRefusalKind { OutOfRange, InventoryFull, Other, + MissingResource, + Class, + Level, + Prerequisite, + ProfileLimit, } impl ActionRefusal { diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 057e49612..6bcbbe4c1 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -356,6 +356,74 @@ 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. +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn apply_equip_profile_upgrade( + ctx: &ReducerContext, + player_guid: u64, + from_slot: u8, +) -> Result { + if from_slot <= BAG_SLOT_END_INCL || !is_carried_slot(from_slot) { + 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..3823545e7 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,47 @@ 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, + item_entry: u32, + target_count: u32, +) -> Result { + profile_item_target_admitted(target_count)?; + 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) +} + +#[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(()); + } + 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. @@ -876,9 +926,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/skill.rs b/module/src/skill.rs index 495889419..fdba95600 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,122 @@ 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) +} + +#[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. 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, + 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 imported = ctx.db.game_skill_availability().count() > 0; + let (admitted, identity_admitted, overflow) = if imported { + let mut admitted = false; + let mut identity_admitted = false; + let mut overflow = false; + 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; + } + 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), + 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 { + 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: cap, + 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/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 80382f8bd..7c1dfcdb3 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, @@ -216,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() @@ -226,26 +232,34 @@ 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 `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, + 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.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 +270,89 @@ 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)) +} + +fn admitted_talent_rank( + ctx: &ReducerContext, + guid: u64, + entity: &crate::WorldEntity, + learned: &[CharacterTalent], + talent: &Talent, +) -> Result { + let spent_in_tree = points_in_tree(ctx, guid, learned, talent); + admitted_talent_rank_with_points(ctx, guid, entity, learned, talent, spent_in_tree) +} + +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 {} is not available to class {} with the current catalogue", + talent.talent_id, + 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 {} is not available to race {} class {}", + talent.talent_id, + entity.race(), + entity.class() + )); + } + } + if talent.required_spell_id != 0 + && !crate::spell::knows_spell(ctx, guid, talent.required_spell_id) + { + return Err(format!( + "talent {} requires spell {}", + talent.talent_id, talent.required_spell_id + )); + } + + 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, + rank_in(learned, talent.required_talent_id), + talent.required_talent_rank, + ); + validate_learn( + current, + talent.max_rank, + available, + spent_in_tree, + talent.required_points_in_tree, + prereq_ok, + ) +} + // =========================================================================================== // Learn + apply // =========================================================================================== @@ -282,24 +374,13 @@ pub(crate) fn do_learn_talent( .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 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 prereq_ok = prereq_satisfied( - talent.required_talent_id, - learned_rank(ctx, guid, talent.required_talent_id), - talent.required_talent_rank, - ); - let next = validate_learn( - current, - talent.max_rank, - available, - pit, - talent.required_points_in_tree, - prereq_ok, - )?; + 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(); @@ -330,6 +411,134 @@ 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. +#[cfg_attr(not(has_packages), allow(dead_code))] +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) +} + +#[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 +/// as `do_learn_talent`. The indexed reads accept at most 64 learned rows, 16 tabs at the requested +/// 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. +#[cfg_attr(not(has_packages), allow(dead_code))] +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 { + let raw_tree: Vec<_> = ctx + .db + .game_talent() + .by_tree() + .filter(&preferred_tree) + .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![] + }; + 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 dda21f50f..c7adfb6e6 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, }; @@ -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,303 @@ 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) +} + +#[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, +) -> 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)] +#[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, + 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}"), + ) + } + } +} + +#[cfg_attr(not(has_packages), allow(dead_code))] +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. +#[cfg_attr(not(has_packages), allow(dead_code))] +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)) +} + +#[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), + 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. +#[cfg_attr(not(has_packages), allow(dead_code))] +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 { + 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) { + 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 diff --git a/module/tests/playerbots_companion.rs b/module/tests/playerbots_companion.rs index 2ce535c9d..e36f34128 100644 --- a/module/tests/playerbots_companion.rs +++ b/module/tests/playerbots_companion.rs @@ -231,12 +231,63 @@ 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!({ "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"), @@ -746,21 +797,33 @@ 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_sql("UPDATE game_spell SET cast_time_ms = 60000 WHERE spell_id = 5090100"); node.assert_call("playerbots_fixture_companion_health", &[ally, "25"]); - due(&node, priest); - assert!(poll_until(POLL_TIMEOUT, || !node + node.assert_call("playerbots_fixture_runner_select_cohort", &[priest]); + pass_once(&node, priest); + 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 objective = runner(&node, priest)["objective_sequence"].clone(); - node.assert_call("playerbots_fixture_cancel", &[priest, "false"]); + .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"]); - 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"); } @@ -769,10 +832,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}" @@ -789,9 +852,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"); } diff --git a/module/tests/playerbots_provisioning.rs b/module/tests/playerbots_provisioning.rs new file mode 100644 index 000000000..be9e6bb23 --- /dev/null +++ b/module/tests/playerbots_provisioning.rs @@ -0,0 +1,936 @@ +//! 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" + )); + 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_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, + "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("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"); + 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"); + 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}"), + ); + 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 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); + 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", + 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"), + "skill_availability": skill_availability, + "preserved_skill_availability": existing_availability, + "skill_staging_refusal": skill_staging_refusal, + }), + ); + 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); + 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" + ); + 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}" + )) + .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"] + ); + } + 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), + "fixture_profile_spells": node.query_rows("SELECT spell_id FROM pkg_playerbots_kit WHERE class = 1 AND role = 0"), + }), + ); +} + +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() { + 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_pass_once", &[&guid]); + 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]); + let staged_food = item_count(&node, &guid, 117); + let wounded_health = health(&node, &guid); + 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, + &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_once", &[&guid]); + 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_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( + &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_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("stopped")); + 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( + &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"); + 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, "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("item:wrong_slot"), "{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)}), + ); +} + +#[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 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() { + 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") + })); + node.assert_call("playerbots_fixture_freeze", &[&guid]); + 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}")), + }), + ); +}