diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index a32c4d6..061ce9d 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -267,13 +267,14 @@ 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" - - 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 + 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" \ @@ -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..98bbc87 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -52,6 +52,34 @@ 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. 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 +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/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..d0e3ad2 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,934 @@ 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; +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; +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) -> Result<(), String> { + use crate::game_skill_availability; + let line = super::provisioning::WARRIOR_PROFILE_SKILL; + let rows = ctx.db.game_skill_availability(); + 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 ordinal in 0..PROVISION_SKILL_AVAILABILITY_ROWS { + rows.insert(crate::SkillAvailability { + id: PROVISION_SKILL_AVAILABILITY_FIRST + u64::from(ordinal), + skill_line: line, + race_mask: 0, + class_mask: if admitted { 1 } else { 1 << 7 }, + flags: 0, + min_level: 1, + }); + } + Ok(()) +} + +/// 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, + max_stack: u32, + inventory_type: u8, + container_slots: u8, + spell_id: u32, + 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() + .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().insert(template); + Ok(()) +} + +#[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, + "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)?; + 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"), + ] { + ensure_profile_item(ctx, entry, name, 20, 0, 0, 0, false)?; + } + 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()); + } + 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()); + } + 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(()) +} + +/// 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 + .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, +) -> 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); + runner_park_for(ctx, guid) +} + +#[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_park_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); + runner_park_for(ctx, guid) +} + +#[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_park_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); + } + ensure_profile_item( + ctx, + PROVISION_QUEST_ITEM, + "Quest Keepsake", + 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() + .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_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, 7386u32)) + .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, + 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_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 == super::provisioning::WARRIOR_PROFILE_SKILL { + skills.id().delete(row.id); + } + } + set_provision_action(ctx, guid, |action| { + action + == super::provisioning::ProvisionAction::Skill( + super::provisioning::WARRIOR_PROFILE_SKILL, + ) + }) +} + +#[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_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)?; + 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); + runner_park_for(ctx, guid) +} + +#[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); + runner_park_for(ctx, guid) +} diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index fb0ce2b..31e4af7 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,19 +706,15 @@ 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) } -/// 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 { diff --git a/playerbots/src/provisioning.rs b/playerbots/src/provisioning.rs new file mode 100644 index 0000000..8cb4a6c --- /dev/null +++ b/playerbots/src/provisioning.rs @@ -0,0 +1,720 @@ +//! 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_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; +const HISTORY_LIMIT: usize = 32; +const MAX_PROFILE_SPELLS: usize = 12; +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 => WARRIOR_PROFILE_SKILL, // 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: crate::actor::select_profile_talent(ctx, bot.character_guid, tree) + .map_err(provision_refusal)?, + })); + 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 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 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, + }; + ProvisionRefusal { + kind, + detail: refusal.detail, + } +} + +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) + } +} + +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")); + } + } + } +} 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);