Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ _Avoid_: invite intent, group request, party order
The durable selector for Legacy, RecordOnly, Cohort, or Frozen behavior. RecordOnly records decisions
and authorizes no bot gameplay. Frozen cancels Foreground Actions and authorizes no new bot gameplay.

**Provisioning Profile**:
A revisioned, bounded upkeep policy for a supported bot class and role. It selects free training and
owned supplies; Module Gates decide every operation. It is distinct from a World Import Profile,
which scopes imported world content.

**Bot Objective**:
A retained purpose with its destination, stage, deadline, catalog revision, and verified progress.
Tactical interruption does not replace the objective. A companion in a human-led party retains the
Expand Down
11 changes: 11 additions & 0 deletions docs/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ supported-lifecycle, range, line-of-sight, and ordinary cast Gates used at cast
turn an `OutOfRange` or `NoLineOfSight` Refusal into a movement prerequisite without spending power,
starting a cooldown, or creating a cast.

Provisioning uses six Actor operations. `select_profile_talent` performs a bounded preferred-tree
read through the owning talent admission calculation. `reconcile_profile_spell`,
`reconcile_profile_skill`, and `learn_profile_talent` return `Result<bool, ActionRefusal>`; `true`
means they changed normal Character state and `false` means the selected profile entry was already
satisfied.
`reconcile_profile_item` returns the granted count and caps its target at 200.
`equip_profile_upgrade` returns whether it equipped the carried item and preserves equal or stronger
gear. These verbs do not authorize an Actor. A Package must first pass the same controller and
Sessionless Action Gate as any other gameplay request. Their grants have no money cost because the
selected profile is the source of the entitlement.

`actor::request_cast(ctx, actor_guid, spell_id, target_guid)` returns
`Result<spell::CastStart, spell::CastRefusal>`. `Started` carries a Cast Handle. `Waiting` carries
an existing cast's original identity, spell, target, and current due time, even when the new request
Expand Down
2 changes: 2 additions & 0 deletions gateway/src/stdb/bindings/spell_effect_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ impl __sdk::__query_builder::HasCols for SpellEffect {
pub struct SpellEffectIxCols {
pub id: __sdk::__query_builder::IxCol<SpellEffect, u64>,
pub spell_id: __sdk::__query_builder::IxCol<SpellEffect, u32>,
pub trigger_spell: __sdk::__query_builder::IxCol<SpellEffect, u32>,
}

impl __sdk::__query_builder::HasIxCols for SpellEffect {
Expand All @@ -95,6 +96,7 @@ impl __sdk::__query_builder::HasIxCols for SpellEffect {
SpellEffectIxCols {
id: __sdk::__query_builder::IxCol::new(table_name, "id"),
spell_id: __sdk::__query_builder::IxCol::new(table_name, "spell_id"),
trigger_spell: __sdk::__query_builder::IxCol::new(table_name, "trigger_spell"),
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions gateway/src/stdb/bindings/talent_tab_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ impl __sdk::__query_builder::HasCols for TalentTab {
///
/// Provides typed access to indexed columns for query building.
pub struct TalentTabIxCols {
pub order_index: __sdk::__query_builder::IxCol<TalentTab, u8>,
pub tab_id: __sdk::__query_builder::IxCol<TalentTab, u32>,
}

impl __sdk::__query_builder::HasIxCols for TalentTab {
type IxCols = TalentTabIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TalentTabIxCols {
order_index: __sdk::__query_builder::IxCol::new(table_name, "order_index"),
tab_id: __sdk::__query_builder::IxCol::new(table_name, "tab_id"),
}
}
Expand Down
2 changes: 2 additions & 0 deletions gateway/src/stdb/bindings/talent_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ impl __sdk::__query_builder::HasCols for Talent {
///
/// Provides typed access to indexed columns for query building.
pub struct TalentIxCols {
pub tab_id: __sdk::__query_builder::IxCol<Talent, u32>,
pub talent_id: __sdk::__query_builder::IxCol<Talent, u32>,
pub tree_id: __sdk::__query_builder::IxCol<Talent, u8>,
}
Expand All @@ -96,6 +97,7 @@ impl __sdk::__query_builder::HasIxCols for Talent {
type IxCols = TalentIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TalentIxCols {
tab_id: __sdk::__query_builder::IxCol::new(table_name, "tab_id"),
talent_id: __sdk::__query_builder::IxCol::new(table_name, "talent_id"),
tree_id: __sdk::__query_builder::IxCol::new(table_name, "tree_id"),
}
Expand Down
2 changes: 2 additions & 0 deletions gateway/src/stdb/bindings/trainer_spell_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ impl __sdk::__query_builder::HasCols for TrainerSpell {
/// Provides typed access to indexed columns for query building.
pub struct TrainerSpellIxCols {
pub id: __sdk::__query_builder::IxCol<TrainerSpell, u64>,
pub spell_id: __sdk::__query_builder::IxCol<TrainerSpell, u32>,
pub trainer_entry: __sdk::__query_builder::IxCol<TrainerSpell, u32>,
}

Expand All @@ -61,6 +62,7 @@ impl __sdk::__query_builder::HasIxCols for TrainerSpell {
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TrainerSpellIxCols {
id: __sdk::__query_builder::IxCol::new(table_name, "id"),
spell_id: __sdk::__query_builder::IxCol::new(table_name, "spell_id"),
trainer_entry: __sdk::__query_builder::IxCol::new(table_name, "trainer_entry"),
}
}
Expand Down
19 changes: 19 additions & 0 deletions module/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
//! | `cast_item_target` | `creatures::apply_item_target_spell` | spell kind + owned carried item + effect-specific gates |
//! | `equip_item` | `items::apply_equip_item` | slot type/level gates |
//! | `trainer_buy` | `trainer::apply_trainer_buy` | trainer resolved by guid + offering gates (class/level/money) |
//! | `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback |
//! | `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates |
//! | `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent Gates |
//! | `learn_profile_talent` | `talent::reconcile_profile_talent` | class/race/level/point/tier/prerequisite gates |
//! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates |
//! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear |
//! | `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates |
//! | `repop` | `world::do_repop` | dead actor releases to the graveyard ghost |
//! | `respond_resurrect` | `spell::do_resurrect_response` | consume the actor's pending rez offer; accept revives IN PLACE at the offer's % |
Expand Down Expand Up @@ -124,6 +130,8 @@ package_only! {
pub(crate) use crate::items::apply_item_use as use_item;
pub(crate) use crate::loot::open_creature_corpse as open_creature_loot;
pub(crate) use crate::items::apply_take_loot as take_loot;
pub(crate) use crate::items::request_profile_item as reconcile_profile_item;
pub(crate) use crate::items::apply_equip_profile_upgrade as equip_profile_upgrade;
// `loot_money` also feeds playerbots' drink-at-rest behavior (work-item 154).
pub(crate) use crate::loot::apply_loot_money as loot_money;
}
Expand All @@ -133,6 +141,12 @@ debug_only! {
pub(crate) use crate::items::apply_equip_item as equip_item;
pub(crate) use crate::items::apply_item_sell as sell_item;
}
package_only! {
pub(crate) use crate::trainer::reconcile_profile_spell as reconcile_profile_spell;
pub(crate) use crate::skill::reconcile_profile_skill as reconcile_profile_skill;
pub(crate) use crate::talent::reconcile_profile_talent as learn_profile_talent;
pub(crate) use crate::talent::select_profile_talent as select_profile_talent;
}

// ---- NPC services / world ----

Expand Down Expand Up @@ -173,6 +187,11 @@ pub enum ActionRefusalKind {
OutOfRange,
InventoryFull,
Other,
MissingResource,
Class,
Level,
Prerequisite,
ProfileLimit,
}

impl ActionRefusal {
Expand Down
68 changes: 68 additions & 0 deletions module/src/items/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,74 @@ pub(crate) fn apply_equip_item(
apply_item_move(ctx, player_guid, from_slot, to_slot)
}

/// Equip a profile item only when its destination is empty or holds lower item-level gear. The
/// existing equip operation still owns every slot, level, proficiency, skill, reputation, and bind
/// Gate. Full bag slots and missing equipped templates preserve what the Character already wears.
#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn apply_equip_profile_upgrade(
ctx: &ReducerContext,
player_guid: u64,
from_slot: u8,
) -> Result<bool, ItemRefusal> {
if from_slot <= BAG_SLOT_END_INCL || !is_carried_slot(from_slot) {
return Err(refuse(
ItemRefusal::WrongSlot,
"profile equipment must come from carried storage",
));
}
let candidate = item_in_slot(ctx, player_guid, from_slot).ok_or_else(|| {
refuse(
ItemRefusal::ItemNotFound,
format!("no item in slot {from_slot}"),
)
})?;
let candidate_template = ctx
.db
.game_item_template()
.entry()
.find(candidate.entry)
.ok_or_else(|| {
refuse(
ItemRefusal::ItemNotFound,
format!("no template for item entry {}", candidate.entry),
)
})?;

let destination = if candidate_template.inventory_type == invtype::BAG {
let Some(slot) = first_free_bag_equip_slot(ctx, player_guid) else {
return Ok(false);
};
slot
} else {
let can_dual_wield = crate::spell::knows_spell(
ctx,
player_guid,
lyracore_shared::constants::dual_wield::SPELL_ID,
);
resolve_equip_slot(candidate_template.inventory_type, can_dual_wield, |slot| {
slot_occupied(ctx, player_guid, slot)
})
.ok_or_else(|| {
refuse(
ItemRefusal::CannotEquip,
format!("item {} is not equippable", candidate.entry),
)
})?
};

if let Some(equipped) = item_in_slot(ctx, player_guid, destination) {
let Some(equipped_template) = ctx.db.game_item_template().entry().find(equipped.entry)
else {
return Ok(false);
};
if equipped_template.item_level >= candidate_template.item_level {
return Ok(false);
}
}
apply_equip_item(ctx, player_guid, from_slot)?;
Ok(true)
}

/// Shared UNEQUIP logic for the player + debug paths: take the item in equipment `from_slot` (0..=18)
/// and move it to the first free backpack slot. Rejects a non-equipment source slot (it's not equipped)
/// or a full backpack. Delegates to `apply_item_move` so the placement is the exact same move primitive;
Expand Down
71 changes: 67 additions & 4 deletions module/src/items/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,23 @@ pub(crate) fn request_grant_item(
count: u32,
property: Option<u32>,
) -> Result<(), ActionRefusal> {
let player = crate::helpers::live_entity(ctx, player_guid)
.map_err(|_| "player not in world".to_string())?;
let player = crate::helpers::live_entity(ctx, player_guid).map_err(|_| {
ActionRefusal::new(
crate::actor::ActionRefusalKind::MissingActor,
"player not in world",
)
})?;
let tmpl = ctx
.db
.game_item_template()
.entry()
.find(item_entry)
.ok_or_else(|| format!("no such item {item_entry}"))?;
.ok_or_else(|| {
ActionRefusal::new(
crate::actor::ActionRefusalKind::MissingResource,
format!("no such item {item_entry}"),
)
})?;
store_item_typed(
ctx,
player_guid,
Expand All @@ -203,6 +212,47 @@ pub(crate) fn request_grant_item(
)
}

/// Top a profile-owned resource up to a fixed target. The target is capped at the largest
/// supported ammunition stack so a malformed profile cannot turn periodic repair into an unlimited
/// grant. Storage still owns capacity, uniqueness, and Random Property Gates.
#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn request_profile_item(
ctx: &ReducerContext,
player_guid: u64,
item_entry: u32,
target_count: u32,
) -> Result<u32, ActionRefusal> {
profile_item_target_admitted(target_count)?;
let owned = ctx
.db
.game_item_instance()
.by_owner_guid()
.filter(&player_guid)
.filter(|row| row.entry == item_entry)
.map(|row| row.stack_count)
.fold(0u32, u32::saturating_add);
let missing = target_count.saturating_sub(owned);
if missing == 0 {
return Ok(0);
}
request_grant_item(ctx, player_guid, item_entry, missing, None)?;
Ok(missing)
}

#[cfg_attr(not(has_packages), allow(dead_code))]
const PROFILE_ITEM_LIMIT: u32 = 200;

#[cfg_attr(not(has_packages), allow(dead_code))]
fn profile_item_target_admitted(target_count: u32) -> Result<(), ActionRefusal> {
if (1..=PROFILE_ITEM_LIMIT).contains(&target_count) {
return Ok(());
}
Err(ActionRefusal::new(
crate::actor::ActionRefusalKind::ProfileLimit,
format!("profile item target {target_count} is outside 1..={PROFILE_ITEM_LIMIT}"),
))
}

/// Add items to matching carried stacks, then free backpack or bag slots.
/// Capacity, uniqueness and every Random Property are resolved before changing any item, so callers may
/// handle an inventory-full Refusal without keeping a partial grant.
Expand Down Expand Up @@ -876,9 +926,22 @@ pub(crate) fn apply_take_loot(
#[cfg(test)]
mod tests {
use super::{
bandage_cooldown_blocks, death_durability_loss, use_spell_for, RECENTLY_BANDAGED_SPELL,
bandage_cooldown_blocks, death_durability_loss, profile_item_target_admitted,
use_spell_for, RECENTLY_BANDAGED_SPELL,
};

#[test]
fn profile_item_target_refuses_zero_and_over_limit_counts() {
for target in [0, 201] {
assert_eq!(
profile_item_target_admitted(target).unwrap_err().kind,
crate::actor::ActionRefusalKind::ProfileLimit
);
}
assert!(profile_item_target_admitted(1).is_ok());
assert!(profile_item_target_admitted(200).is_ok());
}

/// `use_spell_for`: `spellid_1` is the on-use spell IFF it's nonzero AND `spelltrigger_1` names
/// the on-use trigger slot (0 — `ItemSpellTriggerType::OnUse`). A trigger-1 (on-equip) spell is NOT
/// an on-use cast even with a nonzero id, and a zero `spellid_1` (a plain reagent/gear piece/
Expand Down
Loading