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
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ and authorizes no bot gameplay. Frozen cancels Foreground Actions and authorizes

**Bot Objective**:
A retained purpose with its destination, stage, deadline, catalog revision, and verified progress.
Tactical interruption does not replace the objective.
Tactical interruption does not replace the objective. A companion in a human-led party retains the
leader identity while refreshing the destination from current party facts; that refresh does not
replace a retained cast or its identity.

**Foreground Action**:
The single movement or cast retained by one Bot Controller generation.
Expand Down
17 changes: 14 additions & 3 deletions docs/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,20 @@ encounter content; the kernel exists for Packages.
Existing verbs keep `fn verb(ctx, actor_guid, ..) -> Result<(), String>`. The table in
`module/src/actor.rs` lists their contracts.

`actor::cast_readiness(ctx, actor_guid, spell_id, target_guid)` applies the same read-only spellbook,
supported-lifecycle, range, line-of-sight, and ordinary cast Gates used at cast start. A Package can
turn an `OutOfRange` or `NoLineOfSight` Refusal into a movement prerequisite without spending power,
starting a cooldown, or creating a cast.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`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
names a different spell or target. It never restarts that cast. `Resolved` means effects dispatched
synchronously, which does not imply a projectile hit. Channeled spells return `UnsupportedChannel`
until bot requests can retain their lifecycle. Client casts keep their existing channel behavior.
`actor::cast_at` is the compatibility adapter that discards this distinction. These explicit-guid
requests preserve the Actor API's spellbook bypass. They do not prove the Character knows the spell;
capability selection must enforce known, supported spells before proposing them.
`actor::cast_at` is the compatibility adapter that discards this distinction. Character Actor
requests require the spell in `game_player_spell`. Creature and triggered spell-engine entries keep
their existing spellbook exemption.

A scheduled cast ends through `on_cast_finished`. Its payload carries the caster, target, scheduled
identity, and `spell::CastFinish`. Packages must match the scheduled identity before updating retained
Expand All @@ -114,6 +119,7 @@ does not fire this hook. `on_cast_resolved` keeps its existing effect-dispatch c

`spell::pending_cast` reads the current Cast Handle by caster. Refusal kinds are supplied by the
owning Gate. `Other` preserves a Gate's message when no current caller needs a separate policy.
`UnlearnedSpell` and `NoLineOfSight` identify the spellbook and targeted visibility Gates.

`spell::cancel_cast_attempt(ctx, caster_guid, scheduled_id)` cancels only that scheduled cast.
`spell::expire_cast_attempt(ctx, caster_guid, scheduled_id, deadline_micros)` also requires the
Expand All @@ -124,6 +130,11 @@ caller's action deadline to have passed. They return whether they removed the ca
`character_by_guid`, `character_by_name`, `entity_by_owner`, `acting_entity_by_guid`, `entities_near`,
`nearest_entity`, `in_same_partition`, `require_operator`.

`group::party_facts(ctx, character_guid)` reads the Character's local durable party mirror. It names
the leader and every member, with nullable live position, health, and death facts for each member.
An absent membership returns `Ok(None)`. A membership whose Group row is missing returns the typed
`PartyFactsUnavailable`, so a Package can hold party control instead of starting solo behavior.

### Package Config

An Operator-tunable value, seeded by the Package and edited without a republish.
Expand Down
2 changes: 2 additions & 0 deletions module/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//! | `ranged_attack` | `combat::apply_start_ranged_attack` | `attack` gates + ranged weapon equipped (slot 17) |
//! | `stop_attack` | `combat::stop_attack_for` | unconditional disarm of the actor's outgoing melee row |
//! | `cast_at` | `spell::request_cast` | normal cast lifecycle; an existing timed cast waits; level comes from the live entity |
//! | `cast_readiness` | `spell::cast_readiness` | read-only spellbook, supported-lifecycle, and cast Gate check |
//! | `request_cast` | `spell::request_cast` | typed start, waiting, and Refusal; completion uses `on_cast_finished` |
//! | `accept_quest` | `quest::apply_accept_quest` | alive + giver in range offering the quest + level/race/class/prereq/duplicate gates |
//! | `stage_quest` | `quest::grant_quest_unchecked` | HARNESS/BOT staging: same row shape, all accept gates SKIPPED (giver-less) |
Expand Down Expand Up @@ -79,6 +80,7 @@ macro_rules! package_only {
package_only! { pub(crate) use crate::combat::apply_start_attack as attack; }
package_only! { pub(crate) use crate::combat::request_attack as request_attack; }
package_only! { pub(crate) use crate::spell::request_cast as request_cast; }
package_only! { pub(crate) use crate::spell::cast_readiness as cast_readiness; }
debug_only! { pub(crate) use crate::combat::apply_start_ranged_attack as ranged_attack; }

/// Disarm the actor's outgoing auto-attack (melee or ranged). Shape adapter ONLY: the core returns
Expand Down
28 changes: 24 additions & 4 deletions module/src/debug/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,27 @@ pub fn debug_force_cast(
crate::spell::resolve_cast(ctx, character_guid, spell_id, e.level as u8)
}

fn resolve_debug_cast_at(
ctx: &ReducerContext,
caster_guid: u64,
spell_id: u32,
target_guid: u64,
) -> Result<(), String> {
let caster = crate::helpers::live_entity(ctx, caster_guid)?;
// Fixture spells can be staged without being learned. Keep that debug-only bypass while using
// the owning synchronous resolver for costs, targets, effects, and other cast Gates.
crate::spell::resolve_cast_at(
ctx,
caster_guid,
spell_id,
caster.level as u8,
target_guid,
false,
false,
None,
)
}

/// Like `debug_force_cast` but directs the cast at an explicit `target_guid` instead of self —
/// used to test targeted interrupts (e.g. Kick: caster A casts Kick on target B who is mid-cast).
/// For interrupt testing the `lockout_ms` value is read from `game_spell.duration_ms` for the
Expand All @@ -439,8 +460,7 @@ pub fn debug_force_cast_at(
spell_id: u32,
target_guid: u64,
) -> Result<(), String> {
// Actor verb: sources the caster level from the live entity and resolves synchronously.
crate::actor::cast_at(ctx, caster_guid, spell_id, target_guid)
resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid)
}

/// Set `character_guid`'s level and recompute `max_health`/`max_power` from the real stat curve
Expand Down Expand Up @@ -1038,8 +1058,8 @@ pub fn debug_cast_at(
target_guid: u64,
) -> Result<(), String> {
// Debug direct cast resolves synchronously — not a timed-cast completion (instant packet
// sequence); the actor verb sources the caster level from the live entity.
crate::actor::cast_at(ctx, caster_guid, spell_id, target_guid)
// sequence). It bypasses player spellbook admission so fixtures can cast rows they just staged.
resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid)
}

/// Begin a (possibly cast-timed) cast from `caster_guid` AT `target_guid` — drives `begin_cast` so a
Expand Down
3 changes: 3 additions & 0 deletions module/src/debug/repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> {
crate::seed::seed_createinfo_spells(ctx);
let createinfo = ctx.db.game_createinfo_spell().count();

let lesser_heal_target = crate::seed::repair_lesser_heal_target(ctx);

crate::seed::seed_spell_groups(ctx);
crate::items::seed_compatibility_enchantments(ctx);
let spell_groups = ctx.db.game_spell_group().count();
Expand Down Expand Up @@ -350,6 +352,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> {
// already present" signal worth stamping separately.
let total = talents
+ createinfo
+ lesser_heal_target
+ spell_groups
+ pw_shield
+ fixture_catalogue
Expand Down
79 changes: 79 additions & 0 deletions module/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,85 @@ pub(crate) fn group_of(ctx: &ReducerContext, character_guid: u64) -> Option<Grou
.next()
}

#[derive(Clone, Debug, PartialEq)]
pub struct PartyUnitFacts {
pub map_id: u32,
pub instance_id: u64,
pub x: f32,
pub y: f32,
pub z: f32,
pub health: u32,
pub max_health: u32,
pub dead: bool,
}

#[derive(Clone, Debug, PartialEq)]
pub struct PartyMemberFacts {
pub character_guid: u64,
pub unit: Option<PartyUnitFacts>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct PartyFacts {
pub group_id: u64,
pub leader_guid: u64,
pub members: Vec<PartyMemberFacts>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PartyFactsUnavailable {
pub group_id: u64,
}

/// Read one Character's local durable party mirror and current member facts. Membership remains
/// useful when a member has no live entity on this Shard, so those facts are nullable.
#[cfg_attr(not(has_packages), allow(dead_code))]
pub fn party_facts(
ctx: &ReducerContext,
character_guid: u64,
) -> Result<Option<PartyFacts>, PartyFactsUnavailable> {
let Some(member) = group_of(ctx, character_guid) else {
return Ok(None);
};
let group =
ctx.db
.game_group()
.group_id()
.find(member.group_id)
.ok_or(PartyFactsUnavailable {
group_id: member.group_id,
})?;
let members = members_of(ctx, member.group_id)
.into_iter()
.map(|member| {
let unit = ctx
.db
.game_world_entity()
.guid()
.find(member.character_guid)
.map(|entity| PartyUnitFacts {
map_id: entity.map_id,
instance_id: entity.instance_id,
x: entity.x,
y: entity.y,
z: entity.z,
health: entity.health,
max_health: entity.max_health,
dead: entity.dead,
});
PartyMemberFacts {
character_guid: member.character_guid,
unit,
}
})
.collect();
Ok(Some(PartyFacts {
group_id: member.group_id,
leader_guid: group.leader_guid,
members,
}))
}

/// Resolve a membership and its required parent for mutation cores. `Ok(None)` means the
/// Character has no membership; a membership without its Group is a durable invariant failure.
fn checked_group_membership(
Expand Down
10 changes: 5 additions & 5 deletions module/src/gw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,9 @@ pub fn gw_cast_at(
crate::actor::cast_at(ctx, actor_guid, spell_id, target_guid)
}

/// [`crate::spell::scheduler::apply_cast_spell_at`] behind the gateway gate — the GROUND-TARGET
/// [`crate::spell::scheduler::apply_cast_spell_at`] behind the gateway gate. The GROUND-TARGET
/// cast form (`CMSG`-routed `cast_spell_at`): spellbook + NaN + self-target-default gates, target
/// coords carried. Distinct from [`gw_cast_at`], which is the unit-target instant core.
/// coords carried. [`gw_cast_at`] is the unit-target Actor form and applies the same spellbook Gate.
#[reducer]
pub fn gw_cast_spell_at(
ctx: &ReducerContext,
Expand Down Expand Up @@ -1323,9 +1323,9 @@ pub fn gw_loot_master_give(
// Spells (batch B)
// ===========================================================================================

/// [`crate::spell::do_cast_spell`] with the caster named by guid — the SPELLBOOK-GATED player cast
/// (`target_guid == 0` self-casts). Distinct from [`gw_cast_at`], which drives `resolve_cast_at`
/// directly and skips that gate.
/// [`crate::spell::do_cast_spell`] with the caster named by guid. This is the client player-cast form
/// (`target_guid == 0` self-casts). [`gw_cast_at`] applies the same spellbook and cast Gates through
/// the typed Actor request.
#[reducer]
pub fn gw_cast_spell(
ctx: &ReducerContext,
Expand Down
74 changes: 73 additions & 1 deletion module/src/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ fn seed_spell_registry(ctx: &ReducerContext) {
spell(772, "Rend", 1, 10, 0, 5, 21000, 1, 0, true, 0);
effect(772, 0, 0x90, 7, 3000, 1, 0, 0); // bleed 7 dmg / 3s on the target enemy (T_TARGET_ENEMY)
spell(2050, "Lesser Heal", 0, 30, 1500, 40, 0, 2, 0, false, 0);
effect(2050, 0, 0x02, 50, 0, 0, 0, 0); // heal self 50
effect(2050, 0, 0x02, 50, 0, 2, 0, 0); // heal the named ally for 50
spell(133, "Fireball", 0, 0, 0, 30, 0, 4, 0, false, 0);
effect(133, 0, 0x01, 20, 0, 1, 0, 0); // 20 fire dmg to an enemy (lethal -> kill_creature)
spell(
Expand Down Expand Up @@ -1328,6 +1328,78 @@ fn seed_spell_registry(ctx: &ReducerContext) {
seed_spell_groups(ctx);
}

#[cfg(feature = "debug_reducers")]
fn legacy_lesser_heal_header(spell: &Spell) -> bool {
spell.spell_id == 2050
&& spell.name == "Lesser Heal"
&& spell.power_type == 0
&& spell.cost == 30
&& spell.cast_time_ms == 1500
&& spell.gcd_ms == 1500
&& spell.cooldown_ms == 0
&& spell.range_yd == 40
&& spell.duration_ms == 0
&& spell.school_mask == 2
&& spell.dispel_type == 0
&& spell.mechanic == 0
&& spell.max_stacks == 0
&& spell.aura_interrupt == 0
&& spell.attributes == 0
&& spell.spell_level == 0
&& spell.max_level == 0
&& !spell.is_negative
&& spell.cast_flags == 0
&& spell.stances == 0
&& spell.family_name == 0
&& spell.family_flags == 0
&& spell.proc_flags == 0
&& spell.proc_chance == 0
&& spell.proc_charges == 0
}

#[cfg(feature = "debug_reducers")]
fn legacy_lesser_heal_effect(effect: &SpellEffect) -> bool {
effect.id == (2050u64 << 2)
&& effect.spell_id == 2050
&& effect.effect_index == 0
&& effect.kind == 0x02
&& effect.base_points == 50
&& effect.die_sides == 0
&& effect.per_level == 0.0
&& effect.period_ms == 0
&& effect.target == crate::spell::T_SELF
&& effect.radius_yd == 0.0
&& effect.chain_targets == 0
&& effect.trigger_spell == 0
&& effect.effect_mechanic == 0
&& effect.p0 == 0
&& effect.p0_kind == 0
&& effect.p1 == 0
&& effect.script_id == 0
&& !effect.enters_combat
}

/// Repair only the exact former hand-authored Lesser Heal row. Imported or tuned spell data does
/// not match the complete header and effect shapes and remains authoritative.
#[cfg(feature = "debug_reducers")]
pub(crate) fn repair_lesser_heal_target(ctx: &ReducerContext) -> u64 {
let Some(spell) = ctx.db.game_spell().spell_id().find(2050) else {
return 0;
};
let effects = ctx.db.game_spell_effect();
let mut spell_effects: Vec<_> = effects.by_spell().filter(&2050u32).take(2).collect();
if spell_effects.len() != 1 {
return 0;
}
let mut effect = spell_effects.pop().unwrap();
if !legacy_lesser_heal_header(&spell) || !legacy_lesser_heal_effect(&effect) {
return 0;
}
effect.target = crate::spell::T_TARGET_ALLY;
effects.id().update(effect);
1
}

/// Stratum 4 — scheduler arming: the event reaper, instance reaper, creature movement tick, melee
/// swing tick, aura-expiry tick, ground-AoE damage tick, and weather roll. Runs last so nothing
/// fires against a half-seeded database.
Expand Down
Loading