From 00b8dc53fe6bd21ce07f1acc3ea5ba4cf02debb4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:40:13 +0000 Subject: [PATCH 01/25] feat(playerbots): provision supported bot profiles --- .github/workflows/core-tip.yml | 2 + playerbots/README.md | 19 + playerbots/src/decision.rs | 1 + playerbots/src/fixture.rs | 390 ++++++++++++++- playerbots/src/mod.rs | 20 +- playerbots/src/provisioning.rs | 835 +++++++++++++++++++++++++++++++++ playerbots/src/runner.rs | 30 +- 7 files changed, 1286 insertions(+), 11 deletions(-) create mode 100644 playerbots/src/provisioning.rs diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index a32c4d6..f91dba6 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -267,6 +267,7 @@ jobs: manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") PY echo "PLAYERBOTS_COMPANION_PRECEDING_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" + echo "PLAYERBOTS_PB002_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_MANIFEST=$RUNNER_TEMP/playerbots-pb002-preceding-inputs.json" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_CORE=$GITHUB_WORKSPACE/preceding-pb002-core" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_COLLECTION=$GITHUB_WORKSPACE/preceding-pb002-collection" >> "$GITHUB_ENV" @@ -283,6 +284,7 @@ jobs: -p lyracore-module --features=debug_reducers cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ -p lyracore-module --test playerbots_rewrite --test playerbots_companion \ + --test playerbots_provisioning \ --no-fail-fast -- --ignored --test-threads=1 ' - name: Upload fixture inputs and Standalone logs diff --git a/playerbots/README.md b/playerbots/README.md index 2dc250a..522980e 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -52,6 +52,25 @@ one class, three roles, three different rotations, no code that knows the differ A `(class, role)` pair with no kit rows is a pairing this Package cannot fill, and the spawn verb refuses it by name. Adding kit rows for a new pairing makes it legal. +## Provisioning + +Spawn and level-up arm a class-and-role profile. They do not grant anything. The next admitted +`Cohort` runner pass reconciles one missing entry through the core Actor API. Survival, usable +recovery, and defense keep their higher priority. `Legacy`, `RecordOnly`, `Frozen`, a current Account +owner, and a current World Session block every provisioning gameplay write. + +Profiles make training and their bounded item grants free. They cover the configured kit, one class +weapon skill, vanilla talents, gear, four bags, food, drink, potions, ammunition or class reagents, +bandages, and a Hearthstone. Reconciliation only adds profile entries. It never raises level, awards +experience or quest credit, removes quest items, or replaces equal or stronger equipped gear. +One profile accepts at most 12 kit spell rows, 16 tabs at one tree position, and 64 talents in one +tab. A larger catalogue records a typed profile-limit stop instead of silently omitting an entry. + +`pkg_playerbots_provisioning` records the profile, revision, free-grant policy, current cursor, next +repair time, and the last 32 typed decisions. A full inventory, missing catalogue row, death, or +combat records one explanation and backs off. Periodic repair then checks the same bounded profile +without duplicating grants or costs. + `pkg_playerbots_personality` holds the part of a fight the rotation leaves open: where a bot breaks off, and where a healer places a heal. Two bots on one rotation at the same health diverge on those alone. The row is the floor; a Runtime Script can answer for either of them instead. diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index 59f316f..f7f0dd9 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -37,6 +37,7 @@ pub enum Reason { CastingPosition, Resurrection, PartyUnavailable, + Provisioning, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index ebcde82..804c81d 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1,8 +1,8 @@ //! Deterministic staging for private, per-test durable databases. //! Staging replaces shared rotation configuration and is not safe in a shared World Shard. -use super::{pkg_playerbots_bot, PlayerbotsBot}; use super::{pkg_playerbots_personality, pkg_playerbots_rotation}; +use super::{pkg_playerbots_bot, pkg_playerbots_kit, PlayerbotsBot}; use crate::nav::game_nav_chunk; use crate::{ game_creature_spawn, game_creature_template, game_group, game_quest_objective, @@ -1212,3 +1212,391 @@ pub fn playerbots_fixture_runner_wide_recovery( rotations.insert(super::PlayerbotsRotation { id: 0, ..heal }); Ok(()) } + +const PROVISION_QUEST_ITEM: u32 = 5_090_150; +const PROVISION_FILLER: u32 = 5_090_151; + +fn replace_profile_item( + ctx: &ReducerContext, + entry: u32, + name: &str, + max_stack: u32, + inventory_type: u8, + container_slots: u8, + spell_id: u32, + restores_power: bool, +) -> Result<(), String> { + use crate::game_item_template; + let mut template = ctx + .db + .game_item_template() + .entry() + .find(52) + .ok_or("seed food template missing")?; + template.entry = entry; + template.name = name.to_string(); + template.class = if inventory_type == 18 { 1 } else { 0 }; + template.inventory_type = inventory_type; + template.item_level = 1; + template.required_level = 1; + template.max_durability = 0; + template.max_stack = max_stack; + template.container_slots = container_slots; + template.spellid_1 = spell_id; + template.spelltrigger_1 = 0; + template.restores_power = restores_power; + template.bonding = 0; + template.max_count = 0; + template.required_skill = 0; + template.required_skill_rank = 0; + ctx.db.game_item_template().entry().delete(entry); + ctx.db.game_item_template().insert(template); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_catalog(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + replace_profile_item(ctx, 4496, "Provisioning Bag", 1, 18, 4, 0, false)?; + replace_profile_item(ctx, 117, "Provisioning Food", 20, 0, 0, 50115, false)?; + replace_profile_item(ctx, 159, "Provisioning Drink", 20, 0, 0, 50114, true)?; + replace_profile_item(ctx, 118, "Provisioning Potion", 5, 0, 0, 50110, false)?; + replace_profile_item(ctx, 1251, "Provisioning Bandage", 20, 0, 0, 50111, false)?; + replace_profile_item(ctx, 2512, "Provisioning Ammo", 200, 0, 0, 0, false)?; + for (entry, name) in [ + (17033, "Paladin Reagent"), + (17029, "Priest Reagent"), + (17056, "Mage Reagent"), + ] { + replace_profile_item(ctx, entry, name, 20, 0, 0, 0, false)?; + } + Ok(()) +} + +fn provision_state( + ctx: &ReducerContext, + guid: u64, +) -> Result { + use super::provisioning::pkg_playerbots_provisioning; + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .find(guid) + .ok_or_else(|| format!("provisioning state missing for {guid}")) +} + +fn set_provision_action( + ctx: &ReducerContext, + guid: u64, + wanted: impl Fn(super::provisioning::ProvisionAction) -> bool, +) -> Result<(), String> { + use super::provisioning::pkg_playerbots_provisioning; + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + let cursor = super::provisioning::profile_actions(ctx, &bot) + .map_err(|refusal| refusal.detail)? + .iter() + .position(|action| wanted(*action)) + .ok_or("profile action missing")?; + let mut state = provision_state(ctx, guid)?; + state.action_cursor = cursor as u16; + state.next_repair_micros = 0; + state.history.clear(); + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_steps( + ctx: &ReducerContext, + guid: u64, + count: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + if count > 64 { + return Err("provisioning fixture step count exceeds 64".to_string()); + } + for _ in 0..count { + use super::provisioning::pkg_playerbots_provisioning; + let mut state = provision_state(ctx, guid)?; + state.next_repair_micros = 0; + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + let _ = super::provisioning::reconcile_due( + ctx, + &bot, + ctx.timestamp.to_micros_since_unix_epoch(), + ); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_due(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use super::provisioning::pkg_playerbots_provisioning; + let mut state = provision_state(ctx, guid)?; + state.next_repair_micros = 0; + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + runner_due_for(ctx, guid) +} + +#[reducer] +pub fn playerbots_fixture_provision_reset(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use super::provisioning::pkg_playerbots_provisioning; + let mut state = provision_state(ctx, guid)?; + state.action_cursor = 0; + state.next_repair_micros = 0; + state.history.clear(); + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_remove_recovery( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use crate::game_player_spell; + let spells = ctx.db.game_player_spell(); + for row in spells.by_character().filter(guid).collect::>() { + spells.id().delete(row.id); + } + use crate::game_item_instance; + let items = ctx.db.game_item_instance(); + let mut banked_food = false; + for mut row in items + .by_owner_guid() + .filter(guid) + .filter(|row| matches!(row.entry, 117 | 118 | 159)) + .collect::>() + { + if row.entry == 117 && !banked_food { + row.slot = 39; + row.stack_count = 9; + items.guid().update(row); + banked_food = true; + } else { + items.guid().delete(row.guid); + } + } + use super::pkg_playerbots_recovery_scan; + ctx.db + .pkg_playerbots_recovery_scan() + .character_guid() + .delete(guid); + let mut state = provision_state(ctx, guid)?; + state.action_cursor = 0; + state.next_repair_micros = 0; + state.history.clear(); + use super::provisioning::pkg_playerbots_provisioning; + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + runner_due_for(ctx, guid) +} + +#[reducer] +pub fn playerbots_fixture_provision_full_bag( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + playerbots_fixture_provision_catalog(ctx)?; + use crate::{game_item_instance, game_item_template}; + let items = ctx.db.game_item_instance(); + for row in items + .by_owner_guid() + .filter(guid) + .filter(|row| row.slot >= 19) + .collect::>() + { + items.guid().delete(row.guid); + } + replace_profile_item( + ctx, + PROVISION_QUEST_ITEM, + "Quest Keepsake", + 1, + 0, + 0, + 0, + false, + )?; + replace_profile_item(ctx, PROVISION_FILLER, "Bag Filler", 1, 0, 0, 0, false)?; + let mut quest = ctx + .db + .game_item_template() + .entry() + .find(PROVISION_QUEST_ITEM) + .ok_or("quest item template missing")?; + quest.bonding = 4; + ctx.db.game_item_template().entry().update(quest); + crate::items::grant_item(ctx, guid, PROVISION_QUEST_ITEM, 1)?; + crate::items::grant_item(ctx, guid, PROVISION_FILLER, 15)?; + set_provision_action(ctx, guid, |action| { + matches!( + action, + super::provisioning::ProvisionAction::Item(item) + if item.kind == super::provisioning::ProvisionItemKind::Food + ) + }) +} + +#[reducer] +pub fn playerbots_fixture_provision_missing_resource( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + playerbots_fixture_provision_catalog(ctx)?; + use crate::game_item_template; + ctx.db.game_item_template().entry().delete(1251); + set_provision_action(ctx, guid, |action| { + matches!( + action, + super::provisioning::ProvisionAction::Item(item) + if item.kind == super::provisioning::ProvisionItemKind::Supply + && item.entry == 1251 + ) + }) +} + +#[reducer] +pub fn playerbots_fixture_provision_wrong_class_spell( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + if ctx + .db + .pkg_playerbots_kit() + .by_class_role() + .filter((bot.class, bot.role)) + .all(|row| row.spell_id != 133) + { + ctx.db.pkg_playerbots_kit().insert(super::PlayerbotsKit { + id: 0, + class: bot.class, + role: bot.role, + spell_id: 133, + }); + } + set_provision_action(ctx, guid, |action| { + action == super::provisioning::ProvisionAction::Spell(133) + }) +} + +#[reducer] +pub fn playerbots_fixture_provision_profile_overflow( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + let kits = ctx.db.pkg_playerbots_kit(); + let count = kits.by_class_role().filter((bot.class, bot.role)).count(); + for ordinal in count..=12 { + kits.insert(super::PlayerbotsKit { + id: 0, + class: bot.class, + role: bot.role, + spell_id: 600_000 + ordinal as u32, + }); + } + playerbots_fixture_provision_reset(ctx, guid) +} + +#[reducer] +pub fn playerbots_fixture_provision_stronger_weapon( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::items::grant_item(ctx, guid, 50, 1)?; + use crate::game_item_instance; + let slot = ctx + .db + .game_item_instance() + .by_owner_guid() + .filter(guid) + .find(|row| row.entry == 50 && row.slot >= 23) + .map(|row| row.slot) + .ok_or("stronger weapon was not stored")?; + crate::actor::equip_profile_upgrade(ctx, guid, slot) + .map_err(|refusal| refusal.as_tag().to_string())?; + set_provision_action(ctx, guid, |action| { + matches!( + action, + super::provisioning::ProvisionAction::Equip(item) + if item.kind == super::provisioning::ProvisionItemKind::Gear + ) + }) +} + +#[reducer] +pub fn playerbots_fixture_provision_dead(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mut entity = crate::helpers::live_entity(ctx, guid)?; + entity.dead = true; + entity.health = 0; + ctx.db.game_world_entity().guid().update(entity); + let mut state = provision_state(ctx, guid)?; + state.next_repair_micros = 0; + state.history.clear(); + use super::provisioning::pkg_playerbots_provisioning; + ctx.db + .pkg_playerbots_provisioning() + .character_guid() + .update(state); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_levelup(ctx: &ReducerContext, guid: u64) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::stats::set_character_level(ctx, guid, 9)?; + let mut entity = crate::helpers::live_entity(ctx, guid)?; + crate::xp::grant_xp(ctx, &mut entity, 1_000_000); + ctx.db.game_world_entity().guid().update(entity); + Ok(()) +} diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index fb0ce2b..02a8e8d 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -22,9 +22,11 @@ mod actions; mod companion; mod decision; mod goals; +mod provisioning; mod runner; -pub(crate) use runner::*; pub(crate) use actions::*; +pub(crate) use provisioning::*; +pub(crate) use runner::*; #[cfg(feature = "debug_reducers")] mod fixture; pub(crate) use goals::*; @@ -617,8 +619,8 @@ pub(crate) fn role_personality_defaults(role: u8) -> (u8, u8) { } } -/// Create one bot Character of `(class, role)`, place it at `at` on `map_id`, teach it its kit, and -/// register it on the roster. Returns the new Character's guid. +/// Create one bot Character of `(class, role)`, place it at `at` on `map_id`, and register it on the +/// roster. Provisioning is armed here; the first admitted Cohort runner pass teaches its kit. fn spawn_one( ctx: &ReducerContext, class: u8, @@ -677,10 +679,6 @@ fn spawn_one( let entity = crate::creatures::build_player_entity(ctx, &character, Identity::ZERO); ctx.db.game_world_entity().insert(entity); - for spell_id in kit_for(ctx, class, role) { - crate::spell::learn_spell(ctx, guid, Identity::ZERO, spell_id); - } - let (flee_at_pct, heal_at_pct) = role_personality_defaults(role); ctx.db .pkg_playerbots_personality() @@ -690,7 +688,7 @@ fn spawn_one( flee_at_pct, heal_at_pct, }); - ctx.db.pkg_playerbots_bot().insert(PlayerbotsBot { + let bot = ctx.db.pkg_playerbots_bot().insert(PlayerbotsBot { id: 0, character_guid: guid, account_id, @@ -708,6 +706,12 @@ fn spawn_one( scheduler_lag_micros: 0, }); crate::actor::set_sessionless_action_consent(ctx, guid, true); + provisioning::arm_spawn( + ctx, + &bot, + level as u32, + ctx.timestamp.to_micros_since_unix_epoch(), + ); Ok(guid) } diff --git a/playerbots/src/provisioning.rs b/playerbots/src/provisioning.rs new file mode 100644 index 0000000..fbe585b --- /dev/null +++ b/playerbots/src/provisioning.rs @@ -0,0 +1,835 @@ +//! Bounded provisioning for Cohort bots. Spawn and level-up only arm this state. The admitted +//! runner performs one recorded operation when it has no Foreground Action. + +use super::{ + class, pkg_playerbots_bot, pkg_playerbots_kit, Controller, PlayerbotsBot, ROLE_DPS, ROLE_HEALER, +}; +use crate::{ + game_character_talent, game_item_instance, game_player_spell, game_talent, game_talent_tab, + game_world_entity, +}; +use spacetimedb::{table, ReducerContext, Table}; + +const PROFILE_REVISION: u32 = 1; +const STEP_INTERVAL_MICROS: i64 = 1_000_000; +const RETRY_INTERVAL_MICROS: i64 = 30_000_000; +const REPAIR_INTERVAL_MICROS: i64 = 60_000_000; +const HISTORY_LIMIT: usize = 32; +const MAX_PROFILE_SPELLS: usize = 12; +const MAX_PROFILE_TALENTS: usize = 64; +const MAX_TABS_PER_ORDER: usize = 16; +const MAX_ACTION_SCAN: usize = 32; + +const BAG: u32 = 4496; +const FOOD: u32 = 117; +const DRINK: u32 = 159; +const POTION: u32 = 118; +const BANDAGE: u32 = 1251; +const AMMO: u32 = 2512; +const HEARTHSTONE: u32 = 6948; +const PALADIN_REAGENT: u32 = 17033; +const PRIEST_REAGENT: u32 = 17029; +const MAGE_REAGENT: u32 = 17056; + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionCause { + Spawn, + LevelUp, + Periodic, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionItemKind { + Gear, + Bag, + Food, + Drink, + Potion, + Ammo, + Reagent, + Supply, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProvisionItem { + pub kind: ProvisionItemKind, + pub entry: u32, + pub target_count: u32, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProvisionTalent { + pub preferred_tree: u8, + pub talent_id: Option, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionHold { + Dead, + InCombat, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionAction { + RecoverHealth, + RecoverPower, + Skill(u32), + Spell(u32), + Talent(ProvisionTalent), + Item(ProvisionItem), + Equip(ProvisionItem), + Hold(ProvisionHold), + Audit, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionRefusalKind { + MissingResource, + InventoryFull, + Class, + Level, + Prerequisite, + ProfileLimit, + CoreGate, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub struct ProvisionRefusal { + pub kind: ProvisionRefusalKind, + pub detail: String, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProvisionDeferral { + Dead, + InCombat, + MissingConsumable, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum ProvisionOutcome { + Applied(u32), + Satisfied, + Refused(ProvisionRefusal), + Deferred(ProvisionDeferral), + Stopped(ProvisionRefusal), +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub struct ProvisionDecision { + pub at_micros: i64, + pub cause: ProvisionCause, + pub profile: String, + pub revision: u32, + pub action: ProvisionAction, + pub outcome: ProvisionOutcome, +} + +/// One profile cursor and its bounded explanation history per bot. The profile explicitly records +/// that its core-admitted training and item grants are free. +#[table(accessor = pkg_playerbots_provisioning, public)] +pub struct PlayerbotsProvisioning { + #[primary_key] + pub character_guid: u64, + pub profile: String, + pub revision: u32, + pub free_grants: bool, + pub armed_level: u32, + pub cause: ProvisionCause, + pub action_cursor: u16, + pub next_repair_micros: i64, + pub history: Vec, +} + +crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_provisioning(ctx, character_guid) { + ctx.db.pkg_playerbots_provisioning().character_guid().delete(character_guid); +}); +crate::character_owned!(transfer, fn sweep_transfer_pkg_playerbots_provisioning(ctx, character_guid, io) { + table = pkg_playerbots_provisioning, + primary_key = character_guid, +}); + +pub(super) enum ReconcileStep { + Ready, + Recorded, + Worked, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ProvisionEffect { + Other, + RecoveryGrant, + RecoveryUse, +} + +struct Execution { + outcome: ProvisionOutcome, + effect: ProvisionEffect, +} + +impl Execution { + fn other(outcome: ProvisionOutcome) -> Self { + Self { + outcome, + effect: ProvisionEffect::Other, + } + } +} + +fn role_name(role: u8) -> &'static str { + match role { + super::ROLE_TANK => "tank", + ROLE_HEALER => "healer", + _ => "damage", + } +} + +fn class_name(class: u8) -> &'static str { + match class { + class::WARRIOR => "warrior", + class::PALADIN => "paladin", + class::PRIEST => "priest", + class::MAGE => "mage", + _ => "unsupported", + } +} + +fn profile_name(class: u8, role: u8) -> String { + format!("{}-{}-free", class_name(class), role_name(role)) +} + +pub(super) fn arm_spawn(ctx: &ReducerContext, bot: &PlayerbotsBot, level: u32, now: i64) { + let rows = ctx.db.pkg_playerbots_provisioning(); + let row = PlayerbotsProvisioning { + character_guid: bot.character_guid, + profile: profile_name(bot.class, bot.role), + revision: PROFILE_REVISION, + free_grants: true, + armed_level: level, + cause: ProvisionCause::Spawn, + action_cursor: 0, + next_repair_micros: now, + history: vec![], + }; + if rows.character_guid().find(bot.character_guid).is_some() { + rows.character_guid().update(row); + } else { + rows.insert(row); + } +} + +crate::game_hook!(on_levelup, fn playerbots_arm_provisioning_on_levelup(ctx, payload) { + let Some(bot) = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(payload.character_guid) + .next() + else { + return; + }; + let now = ctx.timestamp.to_micros_since_unix_epoch(); + let rows = ctx.db.pkg_playerbots_provisioning(); + let mut state = rows + .character_guid() + .find(payload.character_guid) + .unwrap_or_else(|| PlayerbotsProvisioning { + character_guid: bot.character_guid, + profile: profile_name(bot.class, bot.role), + revision: PROFILE_REVISION, + free_grants: true, + armed_level: payload.new_level, + cause: ProvisionCause::LevelUp, + action_cursor: 0, + next_repair_micros: now, + history: vec![], + }); + state.armed_level = payload.new_level; + state.cause = ProvisionCause::LevelUp; + state.action_cursor = 0; + state.next_repair_micros = now; + if rows.character_guid().find(payload.character_guid).is_some() { + rows.character_guid().update(state); + } else { + rows.insert(state); + } +}); + +fn preferred_skill(class: u8) -> u32 { + match class { + class::WARRIOR => 43, // One-Handed Swords + class::PALADIN | class::PRIEST => 54, // One-Handed Maces + class::MAGE => 136, // Staves + _ => 162, // Unarmed + } +} + +fn preferred_talent_tree(class: u8, role: u8) -> u8 { + match (class, role) { + (class::WARRIOR, super::ROLE_TANK) => 2, + (class::PALADIN, ROLE_HEALER) => 0, + (class::PALADIN, ROLE_DPS) => 2, + (class::PRIEST, ROLE_HEALER) => 1, + (class::PRIEST, ROLE_DPS) => 2, + (class::MAGE, _) => 1, + _ => 0, + } +} + +fn weapon(class: u8) -> u32 { + match class { + class::PRIEST | class::PALADIN => 36, + class::MAGE => 35, + _ => 25, + } +} + +fn item(kind: ProvisionItemKind, entry: u32, target_count: u32) -> ProvisionItem { + ProvisionItem { + kind, + entry, + target_count, + } +} + +fn profile_limit(detail: impl Into) -> ProvisionRefusal { + ProvisionRefusal { + kind: ProvisionRefusalKind::ProfileLimit, + detail: detail.into(), + } +} + +pub(super) fn profile_actions( + ctx: &ReducerContext, + bot: &PlayerbotsBot, +) -> Result, ProvisionRefusal> { + let gear = item(ProvisionItemKind::Gear, weapon(bot.class), 1); + let bag = item(ProvisionItemKind::Bag, BAG, 4); + let mut actions = vec![ + ProvisionAction::RecoverHealth, + ProvisionAction::RecoverPower, + ProvisionAction::Skill(preferred_skill(bot.class)), + ]; + let mut spells: Vec<_> = ctx + .db + .pkg_playerbots_kit() + .by_class_role() + .filter((bot.class, bot.role)) + .take(MAX_PROFILE_SPELLS + 1) + .map(|row| row.spell_id) + .collect(); + if spells.len() > MAX_PROFILE_SPELLS { + return Err(profile_limit(format!( + "profile {} has more than {MAX_PROFILE_SPELLS} spell rows", + profile_name(bot.class, bot.role) + ))); + } + spells.sort_unstable(); + spells.dedup(); + actions.extend(spells.into_iter().map(ProvisionAction::Spell)); + let tree = preferred_talent_tree(bot.class, bot.role); + actions.push(ProvisionAction::Talent(ProvisionTalent { + preferred_tree: tree, + talent_id: next_talent(ctx, bot.character_guid, bot.class, tree)?, + })); + actions.extend([ + ProvisionAction::Item(gear), + ProvisionAction::Equip(gear), + ProvisionAction::Item(bag), + ProvisionAction::Equip(bag), + ProvisionAction::Equip(bag), + ProvisionAction::Equip(bag), + ProvisionAction::Equip(bag), + ProvisionAction::Item(item(ProvisionItemKind::Food, FOOD, 10)), + ]); + if bot.class != class::WARRIOR { + actions.push(ProvisionAction::Item(item( + ProvisionItemKind::Drink, + DRINK, + 10, + ))); + } + actions.push(ProvisionAction::Item(item( + ProvisionItemKind::Potion, + POTION, + 5, + ))); + if bot.class == class::WARRIOR { + actions.push(ProvisionAction::Item(item( + ProvisionItemKind::Ammo, + AMMO, + 200, + ))); + } + let reagent = match bot.class { + class::PALADIN => Some(PALADIN_REAGENT), + class::PRIEST => Some(PRIEST_REAGENT), + class::MAGE => Some(MAGE_REAGENT), + _ => None, + }; + if let Some(entry) = reagent { + actions.push(ProvisionAction::Item(item( + ProvisionItemKind::Reagent, + entry, + 5, + ))); + } + actions.extend([ + ProvisionAction::Item(item(ProvisionItemKind::Supply, BANDAGE, 5)), + ProvisionAction::Item(item(ProvisionItemKind::Supply, HEARTHSTONE, 1)), + ProvisionAction::Audit, + ]); + Ok(actions) +} + +fn mask_admits(mask: u32, id: u8) -> bool { + mask == 0 || (id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0) +} + +fn next_talent( + ctx: &ReducerContext, + guid: u64, + class: u8, + preferred_tree: u8, +) -> Result, ProvisionRefusal> { + let Some(entity) = ctx.db.game_world_entity().guid().find(guid) else { + return Ok(None); + }; + let learned: Vec<_> = ctx + .db + .game_character_talent() + .by_character() + .filter(guid) + .take(MAX_PROFILE_TALENTS + 1) + .collect(); + if learned.len() > MAX_PROFILE_TALENTS { + return Err(profile_limit(format!( + "character {guid} has more than {MAX_PROFILE_TALENTS} learned talent rows" + ))); + } + let spent: u32 = learned.iter().map(|row| u32::from(row.rank)).sum(); + if entity.level.saturating_sub(9).saturating_sub(spent) == 0 { + return Ok(None); + } + let imported = ctx.db.game_talent_tab().count() != 0; + let tabs: Vec<_> = ctx + .db + .game_talent_tab() + .by_order() + .filter(preferred_tree) + .take(MAX_TABS_PER_ORDER + 1) + .collect(); + if tabs.len() > MAX_TABS_PER_ORDER { + return Err(profile_limit(format!( + "talent order {preferred_tree} has more than {MAX_TABS_PER_ORDER} tabs" + ))); + } + let tab = tabs + .into_iter() + .filter(|tab| { + tab.class_mask != 0 + && mask_admits(tab.class_mask, class) + && mask_admits(tab.race_mask, entity.race()) + }) + .map(|tab| tab.tab_id) + .min(); + if imported && tab.is_none() { + return Ok(None); + } + let points_in_tree: u32 = learned + .iter() + .filter_map(|row| { + let talent = ctx.db.game_talent().talent_id().find(row.talent_id)?; + let same = tab.map_or( + talent.tab_id == 0 && talent.tree_id == preferred_tree, + |tab_id| talent.tab_id == tab_id, + ); + same.then_some(u32::from(row.rank)) + }) + .sum(); + let mut candidates: Vec<_> = if let Some(tab_id) = tab { + ctx.db + .game_talent() + .by_tab() + .filter(tab_id) + .take(MAX_PROFILE_TALENTS + 1) + .collect() + } else if class == class::WARRIOR { + ctx.db + .game_talent() + .by_tree() + .filter(preferred_tree) + .filter(|talent| talent.tab_id == 0) + .take(MAX_PROFILE_TALENTS + 1) + .collect() + } else { + vec![] + }; + if candidates.len() > MAX_PROFILE_TALENTS { + return Err(profile_limit(format!( + "talent tab has more than {MAX_PROFILE_TALENTS} rows" + ))); + } + candidates.sort_by_key(|talent| (talent.tier, talent.column, talent.talent_id)); + Ok(candidates.into_iter().find_map(|talent| { + let rank = learned + .iter() + .find(|row| row.talent_id == talent.talent_id) + .map_or(0, |row| row.rank); + let prerequisite_rank = learned + .iter() + .find(|row| row.talent_id == talent.required_talent_id) + .map_or(0, |row| row.rank); + (rank < talent.max_rank + && points_in_tree >= talent.required_points_in_tree + && (talent.required_talent_id == 0 + || prerequisite_rank >= talent.required_talent_rank.max(1)) + && (talent.required_spell_id == 0 + || ctx + .db + .game_player_spell() + .by_character_spell() + .filter((guid, talent.required_spell_id)) + .next() + .is_some())) + .then_some(talent.talent_id) + })) +} + +fn loose_slot(ctx: &ReducerContext, guid: u64, entry: u32) -> Option { + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(guid) + .filter(|row| { + ((23..=38).contains(&row.slot) || (120..=191).contains(&row.slot)) && row.entry == entry + }) + .map(|row| row.slot) + .min() +} + +fn carried_count(ctx: &ReducerContext, guid: u64, entry: u32) -> u32 { + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(guid) + .filter(|row| (row.slot <= 38 || (120..=191).contains(&row.slot)) && row.entry == entry) + .map(|row| row.stack_count) + .sum() +} + +fn core_refusal(refusal: crate::actor::ActionRefusal) -> ProvisionOutcome { + let kind = match refusal.kind { + crate::actor::ActionRefusalKind::MissingResource => ProvisionRefusalKind::MissingResource, + crate::actor::ActionRefusalKind::InventoryFull => ProvisionRefusalKind::InventoryFull, + crate::actor::ActionRefusalKind::Class => ProvisionRefusalKind::Class, + crate::actor::ActionRefusalKind::Level => ProvisionRefusalKind::Level, + crate::actor::ActionRefusalKind::Prerequisite => ProvisionRefusalKind::Prerequisite, + _ => ProvisionRefusalKind::CoreGate, + }; + let refusal = ProvisionRefusal { + kind, + detail: refusal.detail, + }; + if kind == ProvisionRefusalKind::InventoryFull { + ProvisionOutcome::Stopped(refusal) + } else { + ProvisionOutcome::Refused(refusal) + } +} + +fn item_refusal(refusal: lyracore_shared::item::ItemRefusal) -> ProvisionOutcome { + let detail = refusal.as_tag().to_string(); + let refusal = ProvisionRefusal { + kind: match refusal { + lyracore_shared::item::ItemRefusal::InventoryFull => { + ProvisionRefusalKind::InventoryFull + } + lyracore_shared::item::ItemRefusal::ItemNotFound => { + ProvisionRefusalKind::MissingResource + } + lyracore_shared::item::ItemRefusal::RequiredSkill => ProvisionRefusalKind::Prerequisite, + _ => ProvisionRefusalKind::CoreGate, + }, + detail, + }; + if refusal.kind == ProvisionRefusalKind::InventoryFull { + ProvisionOutcome::Stopped(refusal) + } else { + ProvisionOutcome::Refused(refusal) + } +} + +fn request_item(ctx: &ReducerContext, guid: u64, desired: ProvisionItem) -> ProvisionOutcome { + match crate::actor::reconcile_profile_item(ctx, guid, desired.entry, desired.target_count) { + Ok(0) => ProvisionOutcome::Satisfied, + Ok(count) => ProvisionOutcome::Applied(count), + Err(refusal) => core_refusal(refusal), + } +} + +fn recover(ctx: &ReducerContext, guid: u64, desired: ProvisionItem) -> Execution { + let Some(slot) = loose_slot(ctx, guid, desired.entry) else { + return match request_item(ctx, guid, desired) { + ProvisionOutcome::Applied(count) => Execution { + outcome: ProvisionOutcome::Applied(count), + effect: ProvisionEffect::RecoveryGrant, + }, + ProvisionOutcome::Satisfied => Execution::other(ProvisionOutcome::Deferred( + ProvisionDeferral::MissingConsumable, + )), + outcome => Execution::other(outcome), + }; + }; + match crate::actor::use_item(ctx, guid, slot) { + Ok(()) => Execution { + outcome: ProvisionOutcome::Applied(1), + effect: ProvisionEffect::RecoveryUse, + }, + Err(refusal) => Execution::other(item_refusal(refusal)), + } +} + +fn execute( + ctx: &ReducerContext, + bot: &PlayerbotsBot, + action: ProvisionAction, + now: i64, +) -> Execution { + let Some(entity) = ctx.db.game_world_entity().guid().find(bot.character_guid) else { + return Execution::other(ProvisionOutcome::Refused(ProvisionRefusal { + kind: ProvisionRefusalKind::CoreGate, + detail: "bot is not in world".to_string(), + })); + }; + match action { + ProvisionAction::RecoverHealth => { + if entity.health.saturating_mul(2) >= entity.max_health { + Execution::other(ProvisionOutcome::Satisfied) + } else { + let desired = if entity.combat_until_ms > (now / 1000) as u64 { + item(ProvisionItemKind::Potion, POTION, 5) + } else { + item(ProvisionItemKind::Food, FOOD, 10) + }; + recover(ctx, bot.character_guid, desired) + } + } + ProvisionAction::RecoverPower => { + if bot.class == class::WARRIOR + || entity.max_power == 0 + || entity.power.saturating_mul(2) >= entity.max_power + { + Execution::other(ProvisionOutcome::Satisfied) + } else { + recover( + ctx, + bot.character_guid, + item(ProvisionItemKind::Drink, DRINK, 10), + ) + } + } + ProvisionAction::Skill(line) => Execution::other( + match crate::actor::reconcile_profile_skill(ctx, bot.character_guid, line) { + Ok(true) => ProvisionOutcome::Applied(1), + Ok(false) => ProvisionOutcome::Satisfied, + Err(refusal) => core_refusal(refusal), + }, + ), + ProvisionAction::Spell(spell) => Execution::other( + match crate::actor::reconcile_profile_spell(ctx, bot.character_guid, spell) { + Ok(true) => ProvisionOutcome::Applied(1), + Ok(false) => ProvisionOutcome::Satisfied, + Err(refusal) => core_refusal(refusal), + }, + ), + ProvisionAction::Talent(choice) => Execution::other(match choice.talent_id { + None => ProvisionOutcome::Satisfied, + Some(talent) => { + match crate::actor::learn_profile_talent(ctx, bot.character_guid, talent) { + Ok(true) => ProvisionOutcome::Applied(1), + Ok(false) => ProvisionOutcome::Satisfied, + Err(refusal) => core_refusal(refusal), + } + } + }), + ProvisionAction::Item(desired) => { + Execution::other(request_item(ctx, bot.character_guid, desired)) + } + ProvisionAction::Equip(desired) => { + let Some(slot) = loose_slot(ctx, bot.character_guid, desired.entry) else { + return Execution::other( + if carried_count(ctx, bot.character_guid, desired.entry) > 0 { + ProvisionOutcome::Satisfied + } else { + ProvisionOutcome::Deferred(ProvisionDeferral::MissingConsumable) + }, + ); + }; + Execution::other( + match crate::actor::equip_profile_upgrade(ctx, bot.character_guid, slot) { + Ok(true) => ProvisionOutcome::Applied(1), + Ok(false) => ProvisionOutcome::Satisfied, + Err(refusal) => item_refusal(refusal), + }, + ) + } + ProvisionAction::Hold(ProvisionHold::Dead) => { + Execution::other(ProvisionOutcome::Deferred(ProvisionDeferral::Dead)) + } + ProvisionAction::Hold(ProvisionHold::InCombat) => { + Execution::other(ProvisionOutcome::Deferred(ProvisionDeferral::InCombat)) + } + ProvisionAction::Audit => Execution::other(ProvisionOutcome::Satisfied), + } +} + +fn record( + state: &mut PlayerbotsProvisioning, + now: i64, + action: ProvisionAction, + outcome: ProvisionOutcome, +) { + if state.history.len() >= HISTORY_LIMIT { + state.history.remove(0); + } + state.history.push(ProvisionDecision { + at_micros: now, + cause: state.cause, + profile: state.profile.clone(), + revision: state.revision, + action, + outcome, + }); +} + +fn finish_cycle(state: &mut PlayerbotsProvisioning, now: i64) { + state.action_cursor = 0; + state.cause = ProvisionCause::Periodic; + state.next_repair_micros = now.saturating_add(REPAIR_INTERVAL_MICROS); +} + +/// Perform at most one admitted profile mutation. `Worked` consumes the runner tick. A recorded +/// refusal, deferral, or stop backs off and lets the runner continue its selected action. +pub(super) fn reconcile_due(ctx: &ReducerContext, bot: &PlayerbotsBot, now: i64) -> ReconcileStep { + if bot.controller != Controller::Cohort + || crate::actor::sessionless_action_gate(ctx, bot.character_guid).is_err() + { + return ReconcileStep::Ready; + } + let Some(entity) = ctx.db.game_world_entity().guid().find(bot.character_guid) else { + return ReconcileStep::Ready; + }; + let rows = ctx.db.pkg_playerbots_provisioning(); + let mut state = rows + .character_guid() + .find(bot.character_guid) + .unwrap_or_else(|| { + let state = PlayerbotsProvisioning { + character_guid: bot.character_guid, + profile: profile_name(bot.class, bot.role), + revision: PROFILE_REVISION, + free_grants: true, + armed_level: entity.level, + cause: ProvisionCause::Periodic, + action_cursor: 0, + next_repair_micros: now, + history: vec![], + }; + rows.insert(state) + }); + if now < state.next_repair_micros { + return ReconcileStep::Ready; + } + if entity.dead { + let action = ProvisionAction::Hold(ProvisionHold::Dead); + let outcome = execute(ctx, bot, action, now).outcome; + record(&mut state, now, action, outcome); + state.next_repair_micros = now.saturating_add(RETRY_INTERVAL_MICROS); + rows.character_guid().update(state); + return ReconcileStep::Recorded; + } + if entity.combat_until_ms > (now / 1000) as u64 + && entity.health.saturating_mul(2) >= entity.max_health + { + let action = ProvisionAction::Hold(ProvisionHold::InCombat); + let outcome = execute(ctx, bot, action, now).outcome; + record(&mut state, now, action, outcome); + state.next_repair_micros = now.saturating_add(RETRY_INTERVAL_MICROS); + rows.character_guid().update(state); + return ReconcileStep::Recorded; + } + + let actions = match profile_actions(ctx, bot) { + Ok(actions) => actions, + Err(refusal) => { + let action = ProvisionAction::Audit; + record(&mut state, now, action, ProvisionOutcome::Stopped(refusal)); + finish_cycle(&mut state, now); + rows.character_guid().update(state); + return ReconcileStep::Recorded; + } + }; + for _ in 0..MAX_ACTION_SCAN { + let Some(action) = actions.get(usize::from(state.action_cursor)).copied() else { + finish_cycle(&mut state, now); + rows.character_guid().update(state); + return ReconcileStep::Ready; + }; + let execution = execute(ctx, bot, action, now); + let effect = execution.effect; + let outcome = execution.outcome; + if matches!(outcome, ProvisionOutcome::Satisfied) { + state.action_cursor = state.action_cursor.saturating_add(1); + continue; + } + + let worked = matches!(outcome, ProvisionOutcome::Applied(_)); + let supplied_recovery = effect == ProvisionEffect::RecoveryGrant; + let stopped = matches!(outcome, ProvisionOutcome::Stopped(_)); + record(&mut state, now, action, outcome); + if stopped { + finish_cycle(&mut state, now); + } else if supplied_recovery { + state.next_repair_micros = now.saturating_add(STEP_INTERVAL_MICROS); + } else { + state.action_cursor = state.action_cursor.saturating_add(1); + state.next_repair_micros = now.saturating_add(if worked { + STEP_INTERVAL_MICROS + } else { + RETRY_INTERVAL_MICROS + }); + } + rows.character_guid().update(state); + return if worked { + ReconcileStep::Worked + } else { + ReconcileStep::Recorded + }; + } + + finish_cycle(&mut state, now); + rows.character_guid().update(state); + ReconcileStep::Ready +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_supported_profile_names_its_free_grant_policy() { + for class in [class::WARRIOR, class::PALADIN, class::PRIEST, class::MAGE] { + for role in [super::super::ROLE_TANK, ROLE_HEALER, ROLE_DPS] { + assert!(profile_name(class, role).ends_with("-free")); + } + } + } + + #[test] + fn resource_targets_are_bounded_by_the_core_limit() { + for target in [4, 5, 10, 200] { + assert!(target <= 200); + } + } +} diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 285612d..17a12e0 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -21,6 +21,7 @@ const HISTORY_LIMIT: usize = 8; const RECOVERY_SCAN_LIMIT: usize = 24; const FAILURE_LIMIT: usize = 4; const CATALOG_REVISION: u64 = 1; +const DEFENSE_PRIORITY: i32 = 600; #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub enum Controller { @@ -151,6 +152,7 @@ pub enum RunnerOutcome { Refused(Failure), Cancelled, Frozen, + Provisioning, } #[derive(spacetimedb::SpacetimeType, Clone, Debug)] @@ -980,7 +982,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n .as_ref() .map_or(Action::Hold, |target| Action::Attack(target.guid)), Reason::Defense, - 600, + DEFENSE_PRIORITY, ); if threat.is_none() { defense.readiness = Readiness::Refused; @@ -989,7 +991,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n let mut close = node( Action::Move(MoveTarget::Entity(target.guid)), Reason::Defense, - 600, + DEFENSE_PRIORITY, ); if ((target.x - me.x).powi(2) + (target.y - me.y).powi(2)).sqrt() <= 4.0 { close.readiness = Readiness::Complete; @@ -1147,6 +1149,30 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n state.foreground = None; } } + if bot.controller == Controller::Cohort + && state.foreground.is_none() + && chosen.is_none_or(|candidate| candidate.priority < DEFENSE_PRIORITY) + { + match super::provisioning::reconcile_due(ctx, bot, now) { + super::provisioning::ReconcileStep::Ready + | super::provisioning::ReconcileStep::Recorded => {} + super::provisioning::ReconcileStep::Worked => { + let candidate = Candidate { + id: decision::CandidateId { + action: Action::Hold, + reason: Reason::Provisioning, + objective: state.objective_sequence, + }, + priority: 0, + }; + state.chosen = Some(candidate); + state.candidate_order = vec![candidate]; + state.last_outcome = RunnerOutcome::Provisioning; + state.save(ctx); + return; + } + } + } state.chosen = chosen; if let Some(candidate) = chosen { execute(ctx, &me, &destination, candidate, &mut state, now); From cefca469e53525d8cbe61b8e5d2faf39486cda9a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 18:50:30 +0000 Subject: [PATCH 02/25] refactor(playerbots): remove eager kit lookup --- playerbots/src/mod.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index 02a8e8d..31e4af7 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -715,16 +715,6 @@ fn spawn_one( Ok(guid) } -/// The kit rows for a `(class, role)` pair, as spell ids. -pub(crate) fn kit_for(ctx: &ReducerContext, class: u8, role: u8) -> Vec { - ctx.db - .pkg_playerbots_kit() - .by_class_role() - .filter((class, role)) - .map(|row| row.spell_id) - .collect() -} - /// Can this Package field a `(class, role)` bot? The kit table is the answer, so a live SQL insert /// that adds a kit for a new pairing makes that pairing legal with no republish. fn can_fill_role(ctx: &ReducerContext, class: u8, role: u8) -> bool { From c7411316e3826b83c0534b5a2b1d29eca870b523 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:15:08 +0000 Subject: [PATCH 03/25] test(playerbots): expose missing profile spells --- playerbots/README.md | 6 ++++ playerbots/src/fixture.rs | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/playerbots/README.md b/playerbots/README.md index 522980e..b515944 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -71,6 +71,12 @@ repair time, and the last 32 typed decisions. A full inventory, missing catalogu combat records one explanation and backs off. Periodic repair then checks the same bounded profile without duplicating grants or costs. +The default Warrior tank profile names Sunder Armor (7386). Today's seed-only Shard has no spell +header for it. The core Gate returns `MissingResource`, provisioning leaves the spellbook unchanged, +continues with later profile entries, and checks the spell again during periodic repair. Imported +content, or the supported role capability catalogue, must supply the real spell definition before +the profile can learn it. + `pkg_playerbots_personality` holds the part of a fight the rotation leaves open: where a bot breaks off, and where a healer places a heal. Two bots on one rotation at the same health diverge on those alone. The row is the floor; a Runtime Script can answer for either of them instead. diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 804c81d..a47e9a4 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1273,6 +1273,47 @@ pub fn playerbots_fixture_provision_catalog(ctx: &ReducerContext) -> Result<(), Ok(()) } +/// Give the completion scenario an explicit no-import profile whose every spell has a seeded +/// `game_spell` header. The default Warrior tank profile still names Sunder Armor (7386); its +/// missing-resource behavior is exercised separately. +#[reducer] +pub fn playerbots_fixture_provision_complete_profile( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + if bot.class != super::class::WARRIOR || bot.role != super::ROLE_TANK { + return Err("complete profile fixture requires a Warrior tank".to_string()); + } + if ctx.db.game_spell().spell_id().find(355).is_none() { + return Err("seed Taunt spell header missing".to_string()); + } + let kits = ctx.db.pkg_playerbots_kit(); + for row in kits + .by_class_role() + .filter((bot.class, bot.role)) + .filter(|row| row.spell_id == 7386) + .collect::>() + { + kits.id().delete(row.id); + } + if kits + .by_class_role() + .filter((bot.class, bot.role)) + .any(|row| ctx.db.game_spell().spell_id().find(row.spell_id).is_none()) + { + return Err("complete profile fixture contains a spell without a header".to_string()); + } + Ok(()) +} + fn provision_state( ctx: &ReducerContext, guid: u64, @@ -1488,6 +1529,31 @@ pub fn playerbots_fixture_provision_missing_resource( }) } +#[reducer] +pub fn playerbots_fixture_provision_missing_spell( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use crate::{game_player_spell, game_spell, game_spell_effect}; + let spells = ctx.db.game_player_spell(); + for row in spells + .by_character_spell() + .filter((guid, 7386)) + .collect::>() + { + spells.id().delete(row.id); + } + ctx.db.game_spell().spell_id().delete(7386); + let effects = ctx.db.game_spell_effect(); + for row in effects.by_spell().filter(7386u32).collect::>() { + effects.id().delete(row.id); + } + set_provision_action(ctx, guid, |action| { + action == super::provisioning::ProvisionAction::Spell(7386) + }) +} + #[reducer] pub fn playerbots_fixture_provision_wrong_class_spell( ctx: &ReducerContext, From 234672ad990f92f8dbe736affbce8f293f04df9a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 19:22:45 +0000 Subject: [PATCH 04/25] test(playerbots): type the missing spell index key --- playerbots/src/fixture.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index a47e9a4..b6fff0a 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1539,7 +1539,7 @@ pub fn playerbots_fixture_provision_missing_spell( let spells = ctx.db.game_player_spell(); for row in spells .by_character_spell() - .filter((guid, 7386)) + .filter((guid, 7386u32)) .collect::>() { spells.id().delete(row.id); From bb0ec826982e934a2bbb75044d4cfe77049caec9 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:07:29 +0000 Subject: [PATCH 05/25] fix(playerbots): stage bounded profile cases --- playerbots/README.md | 5 +- playerbots/src/fixture.rs | 362 +++++++++++++++++++++++++++++++-- playerbots/src/provisioning.rs | 148 ++------------ 3 files changed, 370 insertions(+), 145 deletions(-) diff --git a/playerbots/README.md b/playerbots/README.md index b515944..98bbc87 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -64,7 +64,10 @@ weapon skill, vanilla talents, gear, four bags, food, drink, potions, ammunition bandages, and a Hearthstone. Reconciliation only adds profile entries. It never raises level, awards experience or quest credit, removes quest items, or replaces equal or stronger equipped gear. One profile accepts at most 12 kit spell rows, 16 tabs at one tree position, and 64 talents in one -tab. A larger catalogue records a typed profile-limit stop instead of silently omitting an entry. +tab. Trainer admission reads at most 16 exact offerings, 16 reverse wrapper effects, and three +effects per candidate wrapper. Each limit reads one extra row to detect overflow. A larger catalogue +records a typed profile-limit outcome when that bounded prefix cannot decide admission. An eligible +offering completes admission without reading later duplicates. `pkg_playerbots_provisioning` records the profile, revision, free-grant policy, current cursor, next repair time, and the last 32 typed decisions. A full inventory, missing catalogue row, death, or diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index b6fff0a..8a5a4e5 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1215,8 +1215,23 @@ pub fn playerbots_fixture_runner_wide_recovery( const PROVISION_QUEST_ITEM: u32 = 5_090_150; const PROVISION_FILLER: u32 = 5_090_151; - -fn replace_profile_item( +const PROVISION_WARRIOR_TRAINER: u32 = 5_090_200; +const PROVISION_OTHER_TRAINER: u32 = 5_090_201; +const PROVISION_LEARN_WRAPPER: u32 = 5_090_202; +const PROVISION_CHANNEL_WRAPPER: u32 = 5_090_203; +const PROVISION_PROC_WRAPPER: u32 = 5_090_204; +const PROVISION_WRONG_CLASS_SPELL: u32 = 5_090_205; +const PROVISION_LOW_LEVEL_SPELL: u32 = 5_090_206; +const PROVISION_PREVIOUS_RANK_SPELL: u32 = 5_090_207; +const PROVISION_OVERFLOW_SPELL: u32 = 5_090_208; +const PROVISION_PREVIOUS_REQUIRED: u32 = 5_090_209; +const PROVISION_DUPLICATE_SUCCESS_SPELL: u32 = 5_090_210; +const PROVISION_WRONG_WRAPPER: u32 = 5_090_211; +const PROVISION_WRONG_WRAPPER_PAYLOAD: u32 = 5_090_212; + +/// Fill a missing low-ID item definition for a private seed-only Shard. Imported or otherwise +/// existing rows remain authoritative and are never changed by this fixture. +fn ensure_profile_item( ctx: &ReducerContext, entry: u32, name: &str, @@ -1227,6 +1242,9 @@ fn replace_profile_item( restores_power: bool, ) -> Result<(), String> { use crate::game_item_template; + if ctx.db.game_item_template().entry().find(entry).is_some() { + return Ok(()); + } let mut template = ctx .db .game_item_template() @@ -1249,7 +1267,6 @@ fn replace_profile_item( template.max_count = 0; template.required_skill = 0; template.required_skill_rank = 0; - ctx.db.game_item_template().entry().delete(entry); ctx.db.game_item_template().insert(template); Ok(()) } @@ -1257,18 +1274,18 @@ fn replace_profile_item( #[reducer] pub fn playerbots_fixture_provision_catalog(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - replace_profile_item(ctx, 4496, "Provisioning Bag", 1, 18, 4, 0, false)?; - replace_profile_item(ctx, 117, "Provisioning Food", 20, 0, 0, 50115, false)?; - replace_profile_item(ctx, 159, "Provisioning Drink", 20, 0, 0, 50114, true)?; - replace_profile_item(ctx, 118, "Provisioning Potion", 5, 0, 0, 50110, false)?; - replace_profile_item(ctx, 1251, "Provisioning Bandage", 20, 0, 0, 50111, false)?; - replace_profile_item(ctx, 2512, "Provisioning Ammo", 200, 0, 0, 0, false)?; + ensure_profile_item(ctx, 4496, "Provisioning Bag", 1, 18, 4, 0, false)?; + ensure_profile_item(ctx, 117, "Provisioning Food", 20, 0, 0, 50115, false)?; + ensure_profile_item(ctx, 159, "Provisioning Drink", 20, 0, 0, 50114, true)?; + ensure_profile_item(ctx, 118, "Provisioning Potion", 5, 0, 0, 50110, false)?; + ensure_profile_item(ctx, 1251, "Provisioning Bandage", 20, 0, 0, 50111, false)?; + ensure_profile_item(ctx, 2512, "Provisioning Ammo", 200, 0, 0, 0, false)?; for (entry, name) in [ (17033, "Paladin Reagent"), (17029, "Priest Reagent"), (17056, "Mage Reagent"), ] { - replace_profile_item(ctx, entry, name, 20, 0, 0, 0, false)?; + ensure_profile_item(ctx, entry, name, 20, 0, 0, 0, false)?; } Ok(()) } @@ -1314,6 +1331,327 @@ pub fn playerbots_fixture_provision_complete_profile( Ok(()) } +fn provision_trainer(ctx: &ReducerContext, entry: u32, class: u8) -> Result<(), String> { + let mut template = ctx + .db + .game_creature_template() + .entry() + .find(51001) + .ok_or("seed trainer template missing")?; + template.entry = entry; + template.name = format!("PB005 class {class} trainer"); + template.trainer_type = lyracore_shared::trainer::trainer_type::CLASS; + template.trainer_class = class; + let templates = ctx.db.game_creature_template(); + templates.entry().delete(entry); + templates.insert(template); + Ok(()) +} + +fn provision_wrapper( + ctx: &ReducerContext, + wrapper: u32, + target: u32, + kind: u8, +) -> Result<(), String> { + let mut header = ctx + .db + .game_spell() + .spell_id() + .find(target) + .ok_or_else(|| format!("seed spell {target} missing"))?; + header.spell_id = wrapper; + header.name = format!("PB005 wrapper for {target}"); + let headers = ctx.db.game_spell(); + headers.spell_id().delete(wrapper); + headers.insert(header); + + let mut effect = ctx + .db + .game_spell_effect() + .by_spell() + .filter(target) + .next() + .ok_or_else(|| format!("seed spell {target} has no effect"))?; + effect.id = (u64::from(wrapper) << 2) | u64::from(effect.effect_index); + effect.spell_id = wrapper; + effect.kind = kind; + effect.trigger_spell = target; + let effects = ctx.db.game_spell_effect(); + for row in effects.by_spell().filter(wrapper).collect::>() { + effects.id().delete(row.id); + } + effects.insert(effect); + Ok(()) +} + +fn provision_spell_clone( + ctx: &ReducerContext, + spell_id: u32, + source_spell: u32, +) -> Result<(), String> { + let mut header = ctx + .db + .game_spell() + .spell_id() + .find(source_spell) + .ok_or_else(|| format!("seed spell {source_spell} missing"))?; + header.spell_id = spell_id; + header.name = format!("PB005 fixture spell {spell_id}"); + let headers = ctx.db.game_spell(); + headers.spell_id().delete(spell_id); + headers.insert(header); + + let source_effects: Vec<_> = ctx + .db + .game_spell_effect() + .by_spell() + .filter(source_spell) + .collect(); + if source_effects.is_empty() { + return Err(format!("seed spell {source_spell} has no effect")); + } + let effects = ctx.db.game_spell_effect(); + for row in effects.by_spell().filter(spell_id).collect::>() { + effects.id().delete(row.id); + } + for mut effect in source_effects { + effect.id = (u64::from(spell_id) << 2) | u64::from(effect.effect_index); + effect.spell_id = spell_id; + effects.insert(effect); + } + Ok(()) +} + +fn provision_offering( + ctx: &ReducerContext, + id: u64, + trainer_entry: u32, + spell_id: u32, + required_level: u8, +) { + use crate::game_trainer_spell; + let rows = ctx.db.game_trainer_spell(); + rows.id().delete(id); + rows.insert(crate::TrainerSpell { + id, + trainer_entry, + spell_id, + cost: 0, + required_level, + learn_skill_line: 0, + learn_skill_cap: 75, + }); +} + +fn provision_kit_spell(ctx: &ReducerContext, bot: &PlayerbotsBot, spell_id: u32) { + let kits = ctx.db.pkg_playerbots_kit(); + if kits + .by_class_role() + .filter((bot.class, bot.role)) + .all(|row| row.spell_id != spell_id) + { + kits.insert(super::PlayerbotsKit { + id: 0, + class: bot.class, + role: bot.role, + spell_id, + }); + } +} + +/// Source-derived rows with the same direct and LearnSpell-wrapper shape as imported trainer data. +/// They prove lookup and Gates only; they are not evidence about an imported World catalogue. +#[reducer] +pub fn playerbots_fixture_provision_trainer_catalog( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let bot = ctx + .db + .pkg_playerbots_bot() + .by_character() + .filter(guid) + .next() + .ok_or("bot missing")?; + if bot.class != super::class::WARRIOR { + return Err("trainer catalogue fixture requires a Warrior".to_string()); + } + for spell in [355, 2050, 139, 133] { + if ctx.db.game_spell().spell_id().find(spell).is_none() { + return Err(format!("seed spell {spell} missing")); + } + } + for (spell, source) in [ + (PROVISION_WRONG_CLASS_SPELL, 133), + (PROVISION_LOW_LEVEL_SPELL, 133), + (PROVISION_PREVIOUS_RANK_SPELL, 133), + (PROVISION_OVERFLOW_SPELL, 133), + (PROVISION_PREVIOUS_REQUIRED, 133), + (PROVISION_DUPLICATE_SUCCESS_SPELL, 133), + (PROVISION_WRONG_WRAPPER_PAYLOAD, 133), + ] { + provision_spell_clone(ctx, spell, source)?; + } + for spell in [ + 355, + 2050, + 139, + 133, + PROVISION_WRONG_CLASS_SPELL, + PROVISION_LOW_LEVEL_SPELL, + PROVISION_PREVIOUS_RANK_SPELL, + PROVISION_OVERFLOW_SPELL, + PROVISION_DUPLICATE_SUCCESS_SPELL, + PROVISION_WRONG_WRAPPER, + ] { + provision_kit_spell(ctx, &bot, spell); + } + provision_trainer(ctx, PROVISION_WARRIOR_TRAINER, super::class::WARRIOR)?; + provision_trainer(ctx, PROVISION_OTHER_TRAINER, super::class::PALADIN)?; + provision_wrapper(ctx, PROVISION_LEARN_WRAPPER, 2050, crate::spell::E_SCRIPTED)?; + provision_wrapper( + ctx, + PROVISION_CHANNEL_WRAPPER, + 139, + crate::spell::A_PERIODIC_TRIGGER, + )?; + provision_wrapper( + ctx, + PROVISION_PROC_WRAPPER, + 133, + crate::spell::A_PROC_TRIGGER, + )?; + provision_wrapper( + ctx, + PROVISION_WRONG_WRAPPER, + PROVISION_WRONG_WRAPPER_PAYLOAD, + crate::spell::E_SCRIPTED, + )?; + + for ordinal in 0..40u64 { + provision_offering( + ctx, + 5_091_000 + ordinal, + PROVISION_WARRIOR_TRAINER, + 5_099_000 + ordinal as u32, + 1, + ); + } + provision_offering(ctx, 5_092_000, PROVISION_WARRIOR_TRAINER, 355, 1); + provision_offering( + ctx, + 5_092_001, + PROVISION_WARRIOR_TRAINER, + PROVISION_LEARN_WRAPPER, + 1, + ); + provision_offering( + ctx, + 5_092_002, + PROVISION_WARRIOR_TRAINER, + PROVISION_CHANNEL_WRAPPER, + 1, + ); + provision_offering( + ctx, + 5_092_003, + PROVISION_WARRIOR_TRAINER, + PROVISION_PROC_WRAPPER, + 1, + ); + provision_offering( + ctx, + 5_092_004, + PROVISION_OTHER_TRAINER, + PROVISION_WRONG_CLASS_SPELL, + 1, + ); + provision_offering( + ctx, + 5_092_005, + PROVISION_WARRIOR_TRAINER, + PROVISION_LOW_LEVEL_SPELL, + 60, + ); + provision_offering( + ctx, + 5_092_006, + PROVISION_WARRIOR_TRAINER, + PROVISION_PREVIOUS_RANK_SPELL, + 1, + ); + provision_offering( + ctx, + 5_092_007, + PROVISION_WARRIOR_TRAINER, + PROVISION_WRONG_WRAPPER, + 1, + ); + for ordinal in 0..17u64 { + provision_offering( + ctx, + 5_093_000 + ordinal, + if ordinal < 16 { + PROVISION_OTHER_TRAINER + } else { + PROVISION_WARRIOR_TRAINER + }, + PROVISION_OVERFLOW_SPELL, + 1, + ); + } + for ordinal in 0..17u64 { + provision_offering( + ctx, + 5_094_000 + ordinal, + PROVISION_WARRIOR_TRAINER, + PROVISION_DUPLICATE_SUCCESS_SPELL, + 1, + ); + } + use crate::game_spell_chain; + ctx.db + .game_spell_chain() + .spell_id() + .delete(PROVISION_PREVIOUS_RANK_SPELL); + ctx.db.game_spell_chain().insert(crate::spell::SpellChain { + spell_id: PROVISION_PREVIOUS_RANK_SPELL, + prev_spell: PROVISION_PREVIOUS_REQUIRED, + first_spell: PROVISION_PREVIOUS_REQUIRED, + rank: 2, + req_spell: 0, + }); + Ok(()) +} + +#[reducer] +pub fn playerbots_fixture_provision_spell_action( + ctx: &ReducerContext, + guid: u64, + spell_id: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + if !matches!( + spell_id, + 355 | 2050 + | 139 + | 133 + | PROVISION_WRONG_CLASS_SPELL + | PROVISION_LOW_LEVEL_SPELL + | PROVISION_PREVIOUS_RANK_SPELL + | PROVISION_OVERFLOW_SPELL + | PROVISION_DUPLICATE_SUCCESS_SPELL + | PROVISION_WRONG_WRAPPER + ) { + return Err("spell is outside the provisioning trainer fixture".to_string()); + } + set_provision_action(ctx, guid, |action| { + action == super::provisioning::ProvisionAction::Spell(spell_id) + }) +} + fn provision_state( ctx: &ReducerContext, guid: u64, @@ -1480,7 +1818,7 @@ pub fn playerbots_fixture_provision_full_bag( { items.guid().delete(row.guid); } - replace_profile_item( + ensure_profile_item( ctx, PROVISION_QUEST_ITEM, "Quest Keepsake", @@ -1490,7 +1828,7 @@ pub fn playerbots_fixture_provision_full_bag( 0, false, )?; - replace_profile_item(ctx, PROVISION_FILLER, "Bag Filler", 1, 0, 0, 0, false)?; + ensure_profile_item(ctx, PROVISION_FILLER, "Bag Filler", 1, 0, 0, 0, false)?; let mut quest = ctx .db .game_item_template() diff --git a/playerbots/src/provisioning.rs b/playerbots/src/provisioning.rs index fbe585b..608081e 100644 --- a/playerbots/src/provisioning.rs +++ b/playerbots/src/provisioning.rs @@ -4,10 +4,7 @@ use super::{ class, pkg_playerbots_bot, pkg_playerbots_kit, Controller, PlayerbotsBot, ROLE_DPS, ROLE_HEALER, }; -use crate::{ - game_character_talent, game_item_instance, game_player_spell, game_talent, game_talent_tab, - game_world_entity, -}; +use crate::{game_item_instance, game_world_entity}; use spacetimedb::{table, ReducerContext, Table}; const PROFILE_REVISION: u32 = 1; @@ -16,8 +13,6 @@ const RETRY_INTERVAL_MICROS: i64 = 30_000_000; const REPAIR_INTERVAL_MICROS: i64 = 60_000_000; const HISTORY_LIMIT: usize = 32; const MAX_PROFILE_SPELLS: usize = 12; -const MAX_PROFILE_TALENTS: usize = 64; -const MAX_TABS_PER_ORDER: usize = 16; const MAX_ACTION_SCAN: usize = 32; const BAG: u32 = 4496; @@ -330,7 +325,8 @@ pub(super) fn profile_actions( let tree = preferred_talent_tree(bot.class, bot.role); actions.push(ProvisionAction::Talent(ProvisionTalent { preferred_tree: tree, - talent_id: next_talent(ctx, bot.character_guid, bot.class, tree)?, + talent_id: crate::actor::select_profile_talent(ctx, bot.character_guid, tree) + .map_err(provision_refusal)?, })); actions.extend([ ProvisionAction::Item(gear), @@ -382,120 +378,6 @@ pub(super) fn profile_actions( Ok(actions) } -fn mask_admits(mask: u32, id: u8) -> bool { - mask == 0 || (id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0) -} - -fn next_talent( - ctx: &ReducerContext, - guid: u64, - class: u8, - preferred_tree: u8, -) -> Result, ProvisionRefusal> { - let Some(entity) = ctx.db.game_world_entity().guid().find(guid) else { - return Ok(None); - }; - let learned: Vec<_> = ctx - .db - .game_character_talent() - .by_character() - .filter(guid) - .take(MAX_PROFILE_TALENTS + 1) - .collect(); - if learned.len() > MAX_PROFILE_TALENTS { - return Err(profile_limit(format!( - "character {guid} has more than {MAX_PROFILE_TALENTS} learned talent rows" - ))); - } - let spent: u32 = learned.iter().map(|row| u32::from(row.rank)).sum(); - if entity.level.saturating_sub(9).saturating_sub(spent) == 0 { - return Ok(None); - } - let imported = ctx.db.game_talent_tab().count() != 0; - let tabs: Vec<_> = ctx - .db - .game_talent_tab() - .by_order() - .filter(preferred_tree) - .take(MAX_TABS_PER_ORDER + 1) - .collect(); - if tabs.len() > MAX_TABS_PER_ORDER { - return Err(profile_limit(format!( - "talent order {preferred_tree} has more than {MAX_TABS_PER_ORDER} tabs" - ))); - } - let tab = tabs - .into_iter() - .filter(|tab| { - tab.class_mask != 0 - && mask_admits(tab.class_mask, class) - && mask_admits(tab.race_mask, entity.race()) - }) - .map(|tab| tab.tab_id) - .min(); - if imported && tab.is_none() { - return Ok(None); - } - let points_in_tree: u32 = learned - .iter() - .filter_map(|row| { - let talent = ctx.db.game_talent().talent_id().find(row.talent_id)?; - let same = tab.map_or( - talent.tab_id == 0 && talent.tree_id == preferred_tree, - |tab_id| talent.tab_id == tab_id, - ); - same.then_some(u32::from(row.rank)) - }) - .sum(); - let mut candidates: Vec<_> = if let Some(tab_id) = tab { - ctx.db - .game_talent() - .by_tab() - .filter(tab_id) - .take(MAX_PROFILE_TALENTS + 1) - .collect() - } else if class == class::WARRIOR { - ctx.db - .game_talent() - .by_tree() - .filter(preferred_tree) - .filter(|talent| talent.tab_id == 0) - .take(MAX_PROFILE_TALENTS + 1) - .collect() - } else { - vec![] - }; - if candidates.len() > MAX_PROFILE_TALENTS { - return Err(profile_limit(format!( - "talent tab has more than {MAX_PROFILE_TALENTS} rows" - ))); - } - candidates.sort_by_key(|talent| (talent.tier, talent.column, talent.talent_id)); - Ok(candidates.into_iter().find_map(|talent| { - let rank = learned - .iter() - .find(|row| row.talent_id == talent.talent_id) - .map_or(0, |row| row.rank); - let prerequisite_rank = learned - .iter() - .find(|row| row.talent_id == talent.required_talent_id) - .map_or(0, |row| row.rank); - (rank < talent.max_rank - && points_in_tree >= talent.required_points_in_tree - && (talent.required_talent_id == 0 - || prerequisite_rank >= talent.required_talent_rank.max(1)) - && (talent.required_spell_id == 0 - || ctx - .db - .game_player_spell() - .by_character_spell() - .filter((guid, talent.required_spell_id)) - .next() - .is_some())) - .then_some(talent.talent_id) - })) -} - fn loose_slot(ctx: &ReducerContext, guid: u64, entry: u32) -> Option { ctx.db .game_item_instance() @@ -518,20 +400,29 @@ fn carried_count(ctx: &ReducerContext, guid: u64, entry: u32) -> u32 { .sum() } -fn core_refusal(refusal: crate::actor::ActionRefusal) -> ProvisionOutcome { +fn provision_refusal(refusal: crate::actor::ActionRefusal) -> ProvisionRefusal { let kind = match refusal.kind { crate::actor::ActionRefusalKind::MissingResource => ProvisionRefusalKind::MissingResource, crate::actor::ActionRefusalKind::InventoryFull => ProvisionRefusalKind::InventoryFull, crate::actor::ActionRefusalKind::Class => ProvisionRefusalKind::Class, crate::actor::ActionRefusalKind::Level => ProvisionRefusalKind::Level, crate::actor::ActionRefusalKind::Prerequisite => ProvisionRefusalKind::Prerequisite, + crate::actor::ActionRefusalKind::ProfileLimit => ProvisionRefusalKind::ProfileLimit, _ => ProvisionRefusalKind::CoreGate, }; - let refusal = ProvisionRefusal { + ProvisionRefusal { kind, detail: refusal.detail, - }; - if kind == ProvisionRefusalKind::InventoryFull { + } +} + +fn core_refusal(refusal: crate::actor::ActionRefusal) -> ProvisionOutcome { + let refusal = provision_refusal(refusal); + let kind = refusal.kind; + if matches!( + kind, + ProvisionRefusalKind::InventoryFull | ProvisionRefusalKind::ProfileLimit + ) { ProvisionOutcome::Stopped(refusal) } else { ProvisionOutcome::Refused(refusal) @@ -825,11 +716,4 @@ mod tests { } } } - - #[test] - fn resource_targets_are_bounded_by_the_core_limit() { - for target in [4, 5, 10, 200] { - assert!(target <= 200); - } - } } From c4113c73f349343438494dc9c52aa87a676774fc Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:15:46 +0000 Subject: [PATCH 06/25] test(playerbots): preserve seeded item template --- playerbots/src/fixture.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 8a5a4e5..8ab234a 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1274,6 +1274,9 @@ fn ensure_profile_item( #[reducer] pub fn playerbots_fixture_provision_catalog(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; + // The seed and World import both own this real low id. Deliberately different values prove that + // fixture staging takes the import-aware no-op path; item 52 is never part of the profile. + ensure_profile_item(ctx, 52, "PB005 preservation probe", 1, 18, 4, 0, true)?; ensure_profile_item(ctx, 4496, "Provisioning Bag", 1, 18, 4, 0, false)?; ensure_profile_item(ctx, 117, "Provisioning Food", 20, 0, 0, 50115, false)?; ensure_profile_item(ctx, 159, "Provisioning Drink", 20, 0, 0, 50114, true)?; From 2a77ff706d1c599a34795ed3947cd50857bdd33c Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:25:18 +0000 Subject: [PATCH 07/25] test(playerbots): stage partial talent catalog --- playerbots/src/fixture.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 8ab234a..ff9ce83 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1228,6 +1228,8 @@ const PROVISION_PREVIOUS_REQUIRED: u32 = 5_090_209; const PROVISION_DUPLICATE_SUCCESS_SPELL: u32 = 5_090_210; const PROVISION_WRONG_WRAPPER: u32 = 5_090_211; const PROVISION_WRONG_WRAPPER_PAYLOAD: u32 = 5_090_212; +const PROVISION_PARTIAL_TALENT_FIRST: u32 = 5_095_000; +const PROVISION_PARTIAL_TALENT_COUNT: u32 = 65; /// Fill a missing low-ID item definition for a private seed-only Shard. Imported or otherwise /// existing rows remain authoritative and are never changed by this fixture. @@ -1276,7 +1278,16 @@ pub fn playerbots_fixture_provision_catalog(ctx: &ReducerContext) -> Result<(), crate::helpers::require_operator(ctx)?; // The seed and World import both own this real low id. Deliberately different values prove that // fixture staging takes the import-aware no-op path; item 52 is never part of the profile. - ensure_profile_item(ctx, 52, "PB005 preservation probe", 1, 18, 4, 0, true)?; + ensure_profile_item( + ctx, + 52, + "Provisioning preservation fixture", + 1, + 18, + 4, + 0, + true, + )?; ensure_profile_item(ctx, 4496, "Provisioning Bag", 1, 18, 4, 0, false)?; ensure_profile_item(ctx, 117, "Provisioning Food", 20, 0, 0, 50115, false)?; ensure_profile_item(ctx, 159, "Provisioning Drink", 20, 0, 0, 50114, true)?; @@ -1334,6 +1345,31 @@ pub fn playerbots_fixture_provision_complete_profile( Ok(()) } +/// Stage a partial Talent import: tabs are absent and 65 imported-style rows remain at the +/// preferred tree position. +#[reducer] +pub fn playerbots_fixture_provision_partial_talent_catalog( + ctx: &ReducerContext, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use crate::{game_talent, game_talent_tab}; + if ctx.db.game_talent_tab().count() != 0 { + return Err("partial talent catalogue requires no tab rows".to_string()); + } + let talents = ctx.db.game_talent(); + for ordinal in 0..PROVISION_PARTIAL_TALENT_COUNT { + let talent_id = PROVISION_PARTIAL_TALENT_FIRST + ordinal; + let mut talent = talents.talent_id().find(1).ok_or("seed talent missing")?; + talent.talent_id = talent_id; + talent.name = format!("Provisioning partial talent {ordinal}"); + talent.tree_id = 2; + talent.tab_id = talent_id; + talents.talent_id().delete(talent_id); + talents.insert(talent); + } + Ok(()) +} + fn provision_trainer(ctx: &ReducerContext, entry: u32, class: u8) -> Result<(), String> { let mut template = ctx .db From a6592041de80388b4f9eeb9da786fb420de21a87 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:35:40 +0000 Subject: [PATCH 08/25] test(playerbots): park explicit runner passes --- playerbots/src/fixture.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index ff9ce83..efda5d9 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1729,7 +1729,7 @@ fn set_provision_action( .pkg_playerbots_provisioning() .character_guid() .update(state); - Ok(()) + runner_park_for(ctx, guid) } #[reducer] @@ -1776,7 +1776,7 @@ pub fn playerbots_fixture_provision_due(ctx: &ReducerContext, guid: u64) -> Resu .pkg_playerbots_provisioning() .character_guid() .update(state); - runner_due_for(ctx, guid) + runner_park_for(ctx, guid) } #[reducer] @@ -1791,7 +1791,7 @@ pub fn playerbots_fixture_provision_reset(ctx: &ReducerContext, guid: u64) -> Re .pkg_playerbots_provisioning() .character_guid() .update(state); - Ok(()) + runner_park_for(ctx, guid) } #[reducer] @@ -1837,7 +1837,7 @@ pub fn playerbots_fixture_provision_remove_recovery( .pkg_playerbots_provisioning() .character_guid() .update(state); - runner_due_for(ctx, guid) + runner_park_for(ctx, guid) } #[reducer] From 99fff1ee06342f94782527e6225395cb8a340461 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 20:56:33 +0000 Subject: [PATCH 09/25] ci(playerbots): run provisioning durability checks --- .github/workflows/core-tip.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index f91dba6..6c01f13 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -8,6 +8,9 @@ on: permissions: contents: read +env: + LYRACORE_REF: 7f48b5842f3bae59cfaffd10f3db40cc534df311 + concurrency: group: core-tip-${{ github.ref }} cancel-in-progress: true @@ -40,11 +43,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the PB-005 LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false @@ -66,11 +69,11 @@ jobs: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the PB-005 LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false @@ -130,11 +133,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out LyraCore main + - name: Check out the PB-005 LyraCore source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: main + ref: ${{ env.LYRACORE_REF }} path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target @@ -271,7 +274,7 @@ jobs: echo "PLAYERBOTS_COMPANION_PRECEDING_MANIFEST=$RUNNER_TEMP/playerbots-pb002-preceding-inputs.json" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_CORE=$GITHUB_WORKSPACE/preceding-pb002-core" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_COLLECTION=$GITHUB_WORKSPACE/preceding-pb002-collection" >> "$GITHUB_ENV" - - name: Run the real Module and Package action fixture + - name: Build the current Wasm and run the durable fixtures working-directory: lyracore env: CARGO_TARGET_DIR: ${{ github.workspace }}/lyracore/target From 04edb9baeb68d4a8cd1925743f6e047aa1e7c6a0 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:04:57 +0000 Subject: [PATCH 10/25] ci(playerbots): follow corrected core head --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 6c01f13..1de10e3 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 7f48b5842f3bae59cfaffd10f3db40cc534df311 + LYRACORE_REF: a19f6a5518f4877a436d13c6d80a993655327788 concurrency: group: core-tip-${{ github.ref }} From 0becf400b8a93d67fac4e4a07569829df6b37cf0 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:24:32 +0000 Subject: [PATCH 11/25] test(playerbots): cover skill and bank admission --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 1de10e3..b670512 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: a19f6a5518f4877a436d13c6d80a993655327788 + LYRACORE_REF: 6106e0816d8fbe29d80067873f4a7ae9ab894c79 concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index efda5d9..5e4c463 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1230,6 +1230,26 @@ const PROVISION_WRONG_WRAPPER: u32 = 5_090_211; const PROVISION_WRONG_WRAPPER_PAYLOAD: u32 = 5_090_212; const PROVISION_PARTIAL_TALENT_FIRST: u32 = 5_095_000; const PROVISION_PARTIAL_TALENT_COUNT: u32 = 65; +const PROVISION_SKILL_AVAILABILITY_ROWS: u32 = 17; + +fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) { + use crate::game_skill_availability; + let line = crate::skill::skill_line::SWORD_1H; + let rows = ctx.db.game_skill_availability(); + for row in rows.by_skill_line().filter(line).collect::>() { + rows.id().delete(row.id); + } + for _ in 0..PROVISION_SKILL_AVAILABILITY_ROWS { + rows.insert(crate::skilldata::SkillAvailability { + id: 0, + skill_line: line, + race_mask: 0, + class_mask: if admitted { 1 } else { 1 << 7 }, + flags: 0, + min_level: 1, + }); + } +} /// Fill a missing low-ID item definition for a private seed-only Shard. Imported or otherwise /// existing rows remain authoritative and are never changed by this fixture. @@ -1323,6 +1343,7 @@ pub fn playerbots_fixture_provision_complete_profile( if bot.class != super::class::WARRIOR || bot.role != super::ROLE_TANK { return Err("complete profile fixture requires a Warrior tank".to_string()); } + replace_profile_skill_availability(ctx, true); if ctx.db.game_spell().spell_id().find(355).is_none() { return Err("seed Taunt spell header missing".to_string()); } @@ -1989,6 +2010,25 @@ pub fn playerbots_fixture_provision_profile_overflow( playerbots_fixture_provision_reset(ctx, guid) } +#[reducer] +pub fn playerbots_fixture_provision_skill_overflow( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + replace_profile_skill_availability(ctx, false); + use crate::game_player_skill; + let skills = ctx.db.game_player_skill(); + for row in skills.by_character().filter(guid).collect::>() { + if row.skill_line == crate::skill::skill_line::SWORD_1H { + skills.id().delete(row.id); + } + } + set_provision_action(ctx, guid, |action| { + action == super::provisioning::ProvisionAction::Skill(crate::skill::skill_line::SWORD_1H) + }) +} + #[reducer] pub fn playerbots_fixture_provision_stronger_weapon( ctx: &ReducerContext, @@ -2016,6 +2056,35 @@ pub fn playerbots_fixture_provision_stronger_weapon( }) } +#[reducer] +pub fn playerbots_fixture_provision_bank_weapon( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + use crate::game_item_instance; + let items = ctx.db.game_item_instance(); + let mut weapon = items + .by_owner_guid() + .filter(guid) + .find(|row| row.entry == 50) + .ok_or("stronger weapon missing")?; + weapon.slot = 39; + items.guid().update(weapon); + runner_park_for(ctx, guid) +} + +#[reducer] +pub fn playerbots_fixture_provision_equip_bank_weapon( + ctx: &ReducerContext, + guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::actor::equip_profile_upgrade(ctx, guid, 39) + .map(|_| ()) + .map_err(|refusal| refusal.as_tag().to_string()) +} + #[reducer] pub fn playerbots_fixture_provision_dead(ctx: &ReducerContext, guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; From 21205940ec02421c24f59d71b085c38764bfa5cb Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:27:52 +0000 Subject: [PATCH 12/25] fix(playerbots): share the profile skill identity --- .github/workflows/core-tip.yml | 2 +- playerbots/src/fixture.rs | 9 ++++++--- playerbots/src/provisioning.rs | 9 +++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index b670512..9c415f6 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 6106e0816d8fbe29d80067873f4a7ae9ab894c79 + LYRACORE_REF: ebfba512281f3219fe20997002d8edfd6c9b3d47 concurrency: group: core-tip-${{ github.ref }} diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 5e4c463..7b9bb66 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1234,7 +1234,7 @@ const PROVISION_SKILL_AVAILABILITY_ROWS: u32 = 17; fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) { use crate::game_skill_availability; - let line = crate::skill::skill_line::SWORD_1H; + let line = super::provisioning::WARRIOR_PROFILE_SKILL; let rows = ctx.db.game_skill_availability(); for row in rows.by_skill_line().filter(line).collect::>() { rows.id().delete(row.id); @@ -2020,12 +2020,15 @@ pub fn playerbots_fixture_provision_skill_overflow( use crate::game_player_skill; let skills = ctx.db.game_player_skill(); for row in skills.by_character().filter(guid).collect::>() { - if row.skill_line == crate::skill::skill_line::SWORD_1H { + if row.skill_line == super::provisioning::WARRIOR_PROFILE_SKILL { skills.id().delete(row.id); } } set_provision_action(ctx, guid, |action| { - action == super::provisioning::ProvisionAction::Skill(crate::skill::skill_line::SWORD_1H) + action + == super::provisioning::ProvisionAction::Skill( + super::provisioning::WARRIOR_PROFILE_SKILL, + ) }) } diff --git a/playerbots/src/provisioning.rs b/playerbots/src/provisioning.rs index 608081e..8cb4a6c 100644 --- a/playerbots/src/provisioning.rs +++ b/playerbots/src/provisioning.rs @@ -8,6 +8,7 @@ use crate::{game_item_instance, game_world_entity}; use spacetimedb::{table, ReducerContext, Table}; const PROFILE_REVISION: u32 = 1; +pub(super) const WARRIOR_PROFILE_SKILL: u32 = 43; const STEP_INTERVAL_MICROS: i64 = 1_000_000; const RETRY_INTERVAL_MICROS: i64 = 30_000_000; const REPAIR_INTERVAL_MICROS: i64 = 60_000_000; @@ -252,10 +253,10 @@ crate::game_hook!(on_levelup, fn playerbots_arm_provisioning_on_levelup(ctx, pay fn preferred_skill(class: u8) -> u32 { match class { - class::WARRIOR => 43, // One-Handed Swords - class::PALADIN | class::PRIEST => 54, // One-Handed Maces - class::MAGE => 136, // Staves - _ => 162, // Unarmed + class::WARRIOR => WARRIOR_PROFILE_SKILL, // One-Handed Swords + class::PALADIN | class::PRIEST => 54, // One-Handed Maces + class::MAGE => 136, // Staves + _ => 162, // Unarmed } } From d776ab1ca07f364e94b17a8b2b9c28c593e8908a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:31:34 +0000 Subject: [PATCH 13/25] test(playerbots): reserve skill fixture rows --- playerbots/src/fixture.rs | 42 ++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 7b9bb66..953d1b2 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1231,17 +1231,44 @@ const PROVISION_WRONG_WRAPPER_PAYLOAD: u32 = 5_090_212; const PROVISION_PARTIAL_TALENT_FIRST: u32 = 5_095_000; const PROVISION_PARTIAL_TALENT_COUNT: u32 = 65; const PROVISION_SKILL_AVAILABILITY_ROWS: u32 = 17; +const PROVISION_SKILL_AVAILABILITY_FIRST: u64 = 5_096_000; -fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) { +fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) -> Result<(), String> { use crate::game_skill_availability; let line = super::provisioning::WARRIOR_PROFILE_SKILL; let rows = ctx.db.game_skill_availability(); - for row in rows.by_skill_line().filter(line).collect::>() { - rows.id().delete(row.id); + let fixture_end = + PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(PROVISION_SKILL_AVAILABILITY_ROWS); + if let Some(row) = rows + .by_skill_line() + .filter(line) + .find(|row| !(PROVISION_SKILL_AVAILABILITY_FIRST..fixture_end).contains(&row.id)) + { + return Err(format!( + "skill line {line} already has non-fixture availability row {}", + row.id + )); + } + for ordinal in 0..PROVISION_SKILL_AVAILABILITY_ROWS { + let id = PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal); + if let Some(row) = rows.id().find(id) { + let fixture_row = row.skill_line == line + && row.race_mask == 0 + && matches!(row.class_mask, 1 | 128) + && row.flags == 0 + && row.min_level == 1; + if !fixture_row { + return Err(format!("skill availability fixture id {id} is occupied")); + } + } + } + for ordinal in 0..PROVISION_SKILL_AVAILABILITY_ROWS { + rows.id() + .delete(PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal)); } - for _ in 0..PROVISION_SKILL_AVAILABILITY_ROWS { + for ordinal in 0..PROVISION_SKILL_AVAILABILITY_ROWS { rows.insert(crate::skilldata::SkillAvailability { - id: 0, + id: PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal), skill_line: line, race_mask: 0, class_mask: if admitted { 1 } else { 1 << 7 }, @@ -1249,6 +1276,7 @@ fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) { min_level: 1, }); } + Ok(()) } /// Fill a missing low-ID item definition for a private seed-only Shard. Imported or otherwise @@ -1343,7 +1371,7 @@ pub fn playerbots_fixture_provision_complete_profile( if bot.class != super::class::WARRIOR || bot.role != super::ROLE_TANK { return Err("complete profile fixture requires a Warrior tank".to_string()); } - replace_profile_skill_availability(ctx, true); + replace_profile_skill_availability(ctx, true)?; if ctx.db.game_spell().spell_id().find(355).is_none() { return Err("seed Taunt spell header missing".to_string()); } @@ -2016,7 +2044,7 @@ pub fn playerbots_fixture_provision_skill_overflow( guid: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - replace_profile_skill_availability(ctx, false); + replace_profile_skill_availability(ctx, false)?; use crate::game_player_skill; let skills = ctx.db.game_player_skill(); for row in skills.by_character().filter(guid).collect::>() { From 3e304896b396059a253ffdf0791230325c22a9bf Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:31:49 +0000 Subject: [PATCH 14/25] ci(playerbots): pin the final skill fixture --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 9c415f6..8ca0ea5 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: ebfba512281f3219fe20997002d8edfd6c9b3d47 + LYRACORE_REF: 650f470dda528075f98737b8b13b77b11c9df0a4 concurrency: group: core-tip-${{ github.ref }} From 681e3180747a1c0211a0a8e2abdc85d0af1158e2 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:36:22 +0000 Subject: [PATCH 15/25] fix(playerbots): use the exported skill fixture type --- playerbots/src/fixture.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 953d1b2..5a63e1d 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -1267,7 +1267,7 @@ fn replace_profile_skill_availability(ctx: &ReducerContext, admitted: bool) -> R .delete(PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal)); } for ordinal in 0..PROVISION_SKILL_AVAILABILITY_ROWS { - rows.insert(crate::skilldata::SkillAvailability { + rows.insert(crate::SkillAvailability { id: PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal), skill_line: line, race_mask: 0, From 8f0df40ce95233cae98580243b643873abb0c6c8 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:43:43 +0000 Subject: [PATCH 16/25] ci(playerbots): pin skill stop acceptance --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 8ca0ea5..f731d18 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 650f470dda528075f98737b8b13b77b11c9df0a4 + LYRACORE_REF: b2e8a8142953667ecf162cd7e5b2a6d22b22e324 concurrency: group: core-tip-${{ github.ref }} From d9019bcd7dc9c3d976bf63ea413065bd7cac1329 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 21:51:38 +0000 Subject: [PATCH 17/25] ci(playerbots): pin owning refusal coverage --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index f731d18..c8621a9 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: b2e8a8142953667ecf162cd7e5b2a6d22b22e324 + LYRACORE_REF: c05b52346fe687b0c7b3faddc563705d6a5c9c08 concurrency: group: core-tip-${{ github.ref }} From 760205950f126fb7402c5e252eec07989e2aab61 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:17:32 +0000 Subject: [PATCH 18/25] test(playerbots): park initial selection and keep build outputs outside source --- .github/workflows/core-tip.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index c8621a9..c4990e9 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: c05b52346fe687b0c7b3faddc563705d6a5c9c08 + LYRACORE_REF: 8b8f10e7178d9e94da5d6655a439d2f9816b71b4 concurrency: group: core-tip-${{ github.ref }} @@ -277,7 +277,7 @@ jobs: - name: Build the current Wasm and run the durable fixtures working-directory: lyracore env: - CARGO_TARGET_DIR: ${{ github.workspace }}/lyracore/target + CARGO_TARGET_DIR: ${{ runner.temp }}/playerbots-current-target run: | cargo clean --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" -p lyracore-module "$GITHUB_WORKSPACE/collection/.github/check-core-tip.sh" "$GITHUB_WORKSPACE/lyracore" \ From 812221c939fe2ed0f1605f5e4cc052e772ea215a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 22:43:58 +0000 Subject: [PATCH 19/25] ci(playerbots): pin rebased provisioning core --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index c4990e9..ccda3e9 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 8b8f10e7178d9e94da5d6655a439d2f9816b71b4 + LYRACORE_REF: d9e044d09afbd8a52ebb61ceeec8a4d368a82f1f concurrency: group: core-tip-${{ github.ref }} From 9a8932b30e49f29c7c177ab97c4f844822f64456 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:13:04 +0000 Subject: [PATCH 20/25] ci(playerbots): pin bounded follow coverage --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index ccda3e9..bac0fd7 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: d9e044d09afbd8a52ebb61ceeec8a4d368a82f1f + LYRACORE_REF: 429b96f456e2e95730cc2d82d950c395e06dad8b concurrency: group: core-tip-${{ github.ref }} From dff71c90afb8fda0c3a74848c11a58edd53da9d8 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:15:14 +0000 Subject: [PATCH 21/25] ci(playerbots): pin deterministic postcast coverage --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index bac0fd7..4161a22 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 429b96f456e2e95730cc2d82d950c395e06dad8b + LYRACORE_REF: 864d71542b798a1ebd28e1a76e20eea47175ccd6 concurrency: group: core-tip-${{ github.ref }} From f48b4ee777c64c7f17ded45b5ec0dfffb33ecfa7 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:19:45 +0000 Subject: [PATCH 22/25] ci(playerbots): pin explicit cancellation proof --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 4161a22..5885265 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 864d71542b798a1ebd28e1a76e20eea47175ccd6 + LYRACORE_REF: 35f0e5247c2be7baf6c2bfed2788456b6acf84a3 concurrency: group: core-tip-${{ github.ref }} From 3274f2eb5c4706c789f3c0ff180787fced16153d Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:27:26 +0000 Subject: [PATCH 23/25] test(playerbots): park provisioning fixtures --- playerbots/src/fixture.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playerbots/src/fixture.rs b/playerbots/src/fixture.rs index 5a63e1d..d0e3ad2 100644 --- a/playerbots/src/fixture.rs +++ b/playerbots/src/fixture.rs @@ -2131,7 +2131,7 @@ pub fn playerbots_fixture_provision_dead(ctx: &ReducerContext, guid: u64) -> Res .pkg_playerbots_provisioning() .character_guid() .update(state); - Ok(()) + runner_park_for(ctx, guid) } #[reducer] @@ -2141,5 +2141,5 @@ pub fn playerbots_fixture_provision_levelup(ctx: &ReducerContext, guid: u64) -> let mut entity = crate::helpers::live_entity(ctx, guid)?; crate::xp::grant_xp(ctx, &mut entity, 1_000_000); ctx.db.game_world_entity().guid().update(entity); - Ok(()) + runner_park_for(ctx, guid) } From 172a78125c413350db9e6ae255c9091274b0aa52 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:29:48 +0000 Subject: [PATCH 24/25] ci(playerbots): pin reviewed Actor docs --- .github/workflows/core-tip.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 5885265..29c88e9 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - LYRACORE_REF: 35f0e5247c2be7baf6c2bfed2788456b6acf84a3 + LYRACORE_REF: 58867b6f6fe411692b08ab2e0d67c7f090bf42ee concurrency: group: core-tip-${{ github.ref }} From 0ef346599e11c9d932f13909bb166a2f27affee4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 23:53:40 +0000 Subject: [PATCH 25/25] ci(playerbots): track merged core --- .github/workflows/core-tip.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 29c88e9..061ce9d 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -8,9 +8,6 @@ on: permissions: contents: read -env: - LYRACORE_REF: 58867b6f6fe411692b08ab2e0d67c7f090bf42ee - concurrency: group: core-tip-${{ github.ref }} cancel-in-progress: true @@ -43,11 +40,11 @@ jobs: path: collection persist-credentials: false - - name: Check out the PB-005 LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false @@ -69,11 +66,11 @@ jobs: path: collection persist-credentials: false - - name: Check out the PB-005 LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false @@ -133,11 +130,11 @@ jobs: with: path: collection persist-credentials: false - - name: Check out the PB-005 LyraCore source + - name: Check out LyraCore main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: LyraCoreProject/LyraCore - ref: ${{ env.LYRACORE_REF }} + ref: main path: lyracore persist-credentials: false - name: Install the Rust toolchain and Wasm target