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
2 changes: 1 addition & 1 deletion .github/workflows/module-durable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
# `lethal_floor`'s ranged phase races the module's own cast-event sweep and health regen, and
# is flaky on `main` with or without this change.
- name: Run the durable suite
run: cargo test -p lyracore-module --tests -- --ignored --test-threads=4 --skip deadmines --skip personality --skip lethal_floor
run: cargo test -p lyracore-module --tests -- --ignored --test-threads=4 --skip deadmines --skip personality --skip playerbots_ --skip lethal_floor

- name: Run the single-shard auction Refusal regression
run: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::reducers::auction_reducer_tests::a_local_bid_interaction_refusal_stays_a_client_outcome -- --ignored --exact
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
18 changes: 18 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,24 @@ from the old schema. New rows use 1 START, 2 GO, 3 INTERRUPT, 4 PUSHBACK, or 5 P
does not guess how to decode an unknown nonzero code.
_Avoid_: cast flags, event flags, signal flags

**Item Exchange**:
A complete plan of carried-item consumption and item grants. Capacity, uniqueness, Random Property,
and identity allocation Gates pass before any inventory row changes. Quest rewards commit as one
exchange even when the caller retains a Refusal and continues.

**Action Outcome**:
An observation returned by the operation that owns an action. Acceptance records that work was
admitted; completion and progress require evidence from that work. A synchronous interaction ends
with success or Refusal. A timed cast also retains waiting and terminal outcomes.

**Route Step**:
A planned movement leg with its route status, first waypoint, collision clipping, and coverage
evidence. Its endpoint is proposed movement, not proof of advancement or arrival.

**Cast Handle**:
The scheduled identity, spell, target, and current due time of one timed cast. Repeated bot requests
retain this identity until the cast finishes or an explicit cancellation removes it.

### Creature AI

**Creature-AI Family**:
Expand Down
1 change: 1 addition & 0 deletions crates/lyracore-package-delta/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub const HOOK_EVENT_NAMES: &[&str] = &[
"on_kill",
"on_aggro",
"on_cast_resolved",
"on_cast_finished",
"on_loot",
"on_quest_accept",
"on_quest_turnin",
Expand Down
152 changes: 138 additions & 14 deletions crates/lyracore-shared/src/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,27 @@ pub fn find_leg_ex(
to: (f32, f32),
max_expansions: u32,
) -> Option<(Vec<(f32, f32)>, u32, bool)> {
search_leg(fetch, from, to, 0.0, max_expansions)
let search = search_leg(fetch, from, to, 0.0, max_expansions);
match search.outcome {
LegOutcome::Complete(path) => Some((path, search.expansions, true)),
LegOutcome::Partial(path) => Some((path, search.expansions, false)),
LegOutcome::Blocked => None,
}
}

/// Planning success is distinct from arrival. Partial legs retain safe progress toward the goal.
#[derive(Clone, Debug, PartialEq)]
pub enum LegOutcome {
Complete(Vec<(f32, f32)>),
Partial(Vec<(f32, f32)>),
Blocked,
}

/// Search work is retained even when no progress is possible.
#[derive(Clone, Debug, PartialEq)]
pub struct LegSearch {
pub outcome: LegOutcome,
pub expansions: u32,
}

/// Find a leg to a walkable point within the caller's stopping distance of the target.
Expand All @@ -354,26 +374,58 @@ pub fn find_leg_in_range(
stop_dist: f32,
max_expansions: u32,
) -> Option<Vec<(f32, f32)>> {
search_leg(fetch, from, to, stop_dist.max(0.0), max_expansions).map(|(path, _, _)| path)
match find_leg_in_range_ex(fetch, from, to, stop_dist, max_expansions).outcome {
LegOutcome::Complete(path) | LegOutcome::Partial(path) => Some(path),
LegOutcome::Blocked => None,
}
}

/// Find a range-limited leg without discarding completeness or failed-search work.
pub fn find_leg_in_range_ex(
fetch: &mut impl FnMut(u16, u16) -> Option<NavCellData>,
from: (f32, f32),
to: (f32, f32),
stop_dist: f32,
max_expansions: u32,
) -> LegSearch {
if !stop_dist.is_finite() {
return LegSearch {
outcome: LegOutcome::Blocked,
expansions: 0,
};
}
search_leg(fetch, from, to, stop_dist.max(0.0), max_expansions)
}

#[allow(clippy::type_complexity)]
fn search_leg(
fetch: &mut impl FnMut(u16, u16) -> Option<NavCellData>,
from: (f32, f32),
to: (f32, f32),
stop_dist: f32,
max_expansions: u32,
) -> Option<(Vec<(f32, f32)>, u32, bool)> {
let (sx, sy) = (grid_coord(from.0)?, grid_coord(from.1)?);
let (tx, ty) = (grid_coord(to.0)?, grid_coord(to.1)?);
) -> LegSearch {
let blocked = |expansions| LegSearch {
outcome: LegOutcome::Blocked,
expansions,
};
let (Some(sx), Some(sy), Some(tx), Some(ty)) = (
grid_coord(from.0),
grid_coord(from.1),
grid_coord(to.0),
grid_coord(to.1),
) else {
return blocked(0);
};
if !stop_dist.is_finite() {
return None;
return blocked(0);
}
let mut cache = Cache::new(fetch);
let distance = (to.0 - from.0).hypot(to.1 - from.1);
if distance <= stop_dist {
return Some((vec![from], 0, true));
return LegSearch {
outcome: LegOutcome::Complete(vec![from]),
expansions: 0,
};
}
let direct = if stop_dist > 0.0 {
let scale = (distance - stop_dist) / distance;
Expand All @@ -385,11 +437,14 @@ fn search_leg(
to
};
if line_walkable(&mut cache, from, direct) {
return Some((vec![direct], 0, true));
return LegSearch {
outcome: LegOutcome::Complete(vec![direct]),
expansions: 0,
};
}
let target_walkable = grid_walkable(&mut cache, tx, ty);
if stop_dist == 0.0 && !target_walkable {
return None;
return blocked(0);
}
// A* with octile heuristic, integer costs (10 straight / 14 diagonal), corner-cut guard.
use std::cmp::Reverse;
Expand Down Expand Up @@ -420,10 +475,10 @@ fn search_leg(
best.0 = (x, y);
break;
}
expanded += 1;
if expanded > max_expansions {
if expanded == max_expansions {
break; // budget exhausted — fall through to the best-effort partial path
}
expanded += 1;
let hxy = h(x, y);
if hxy < best.1 {
best = ((x, y), hxy);
Expand Down Expand Up @@ -467,7 +522,7 @@ fn search_leg(
best.0
} else {
if best.0 == (sx, sy) {
return None; // zero progress possible (walled-in start) — caller falls back
return blocked(expanded);
}
best.0
};
Expand Down Expand Up @@ -510,7 +565,14 @@ fn search_leg(
path.pop();
path.push(to);
}
Some((path, expanded, found))
LegSearch {
outcome: if found {
LegOutcome::Complete(path)
} else {
LegOutcome::Partial(path)
},
expansions: expanded,
}
}

#[cfg(test)]
Expand Down Expand Up @@ -640,6 +702,68 @@ mod runtime_tests {
assert!(complete);
}

#[test]
fn range_search_completes_before_a_blocked_target() {
let from = at(10, 50);
let to = at(32, 50);
let search = find_leg_in_range_ex(&mut fetcher(), from, to, 4.0, 4096);
let LegOutcome::Complete(path) = search.outcome else {
panic!("the stopping distance ends before the wall");
};
assert_eq!(search.expansions, 0);
let endpoint = *path.last().unwrap();
assert!(((endpoint.0 - to.0).hypot(endpoint.1 - to.1) - 4.0).abs() < 0.01);
assert_ne!(endpoint, from);
}

#[test]
fn range_search_retains_safe_partial_progress_at_the_expansion_cap() {
let from = at(50, 8);
let to = at(8, 8);
let search = find_leg_in_range_ex(&mut fetcher(), from, to, 0.0, 32);
let LegOutcome::Partial(path) = search.outcome else {
panic!("the sealed pocket permits only partial progress");
};
assert_eq!(search.expansions, 32);
assert_ne!(path.last().copied(), Some(from));
let mut fetch = fetcher();
let mut cache = Cache::new(&mut fetch);
let mut previous = from;
for point in path {
assert!(line_walkable(&mut cache, previous, point));
previous = point;
}
}

#[test]
fn blocked_search_retains_expansions_without_a_straight_fallback() {
let ((cx, cy), mut cell) = walled_cell();
cell.walk.fill(0);
walk_set(&mut cell.walk, 10, 10, true);
walk_set(&mut cell.walk, 50, 50, true);
let mut fetch = |x, y| (x == cx && y == cy).then(|| cell.clone());
let search = find_leg_in_range_ex(&mut fetch, at(10, 10), at(50, 50), 0.0, 4096);
assert_eq!(search.outcome, LegOutcome::Blocked);
assert_eq!(search.expansions, 1);
}

#[test]
fn zero_search_budget_never_expands_a_node() {
let search = find_leg_in_range_ex(&mut fetcher(), at(10, 50), at(54, 50), 0.0, 0);
assert_eq!(search.outcome, LegOutcome::Blocked);
assert_eq!(search.expansions, 0);
}

#[test]
fn nonfinite_stopping_distance_does_not_produce_a_route() {
for distance in [f32::NAN, f32::INFINITY, -f32::INFINITY] {
let search =
find_leg_in_range_ex(&mut fetcher(), at(10, 50), at(20, 50), distance, 4096);
assert_eq!(search.outcome, LegOutcome::Blocked);
assert_eq!(search.expansions, 0);
}
}

#[test]
fn start_inside_a_blocked_margin_cell_still_fast_paths() {
// A chaser standing IN an unwalkable margin cell (movement isn't walkability-gated)
Expand Down
55 changes: 52 additions & 3 deletions docs/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ authoritative; this list is the set of event names and their payload types.
| `on_kill` | `crate::hooks::KillPayload` |
| `on_aggro` | `crate::hooks::AggroPayload` |
| `on_cast_resolved` | `crate::hooks::CastResolvedPayload` |
| `on_cast_finished` | `crate::hooks::CastFinishedPayload` |
| `on_loot` | `crate::hooks::LootPayload` |
| `on_quest_accept` | `crate::hooks::QuestAcceptPayload` |
| `on_quest_turnin` | `crate::hooks::QuestTurninPayload` |
Expand All @@ -81,9 +82,32 @@ encounter content; the kernel exists for Packages.

### Actor verbs and helpers

`crate::actor` is the documented verb set over every explicit-guid action: one uniform shape,
`fn verb(ctx, actor_guid, ..) -> Result<(), String>`, with the gates of the core operation it names.
The table at the top of `module/src/actor.rs` lists every verb and its gate semantics.
`crate::actor` holds explicit-guid operations with the Gates of the core operation each names.
Existing verbs keep `fn verb(ctx, actor_guid, ..) -> Result<(), String>`. The table in
`module/src/actor.rs` lists their contracts.

`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.

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
work. The hook runs after the pending row is removed. An instant cast returns its result directly and
does not fire this hook. `on_cast_resolved` keeps its existing effect-dispatch contract.

`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.

`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
caller's action deadline to have passed. They return whether they removed the cast and report
`Cancelled` or `Expired` through `on_cast_finished`. A stale identity cannot cancel a replacement.

`crate::helpers` holds the reads a Package needs before it acts: `live_entity`, `require_character`,
`character_by_guid`, `character_by_name`, `entity_by_owner`, `acting_entity_by_guid`, `entities_near`,
Expand Down Expand Up @@ -177,3 +201,28 @@ so the surface can grow or the Package can move off it.

An exemption cannot enable a whole-crate alias or `#[path]`. Either spelling can hide dependencies
on other lines or in files the lint cannot locate, so the build always refuses it.

## Action observations

`actor::request_attack` returns `AttackStart::Armed` or `AlreadyArmed`. The latter keeps the current
melee swing timer. Both mean an engagement was accepted; neither proves a hit. The existing
`actor::attack` and client operations retain their re-arm behavior.

`actor::request_accept_quest` and `request_turn_in_quest` complete synchronously. They return
`Result<(), ActionRefusal>`, with the same core Gates and effects as the client operations. They
have no waiting, cancellation, or expiry phase. Attack requests also return `ActionRefusal`.
Its `kind` identifies actionable Gates and `detail` preserves the client message. `Other` is an
opaque refusal; Package policy must not classify its text.

`nav::route_step` accepts the same arguments as `nav_step` and returns a `RouteStep`. Its `status`
is `Complete`, `Partial`, `Blocked`, or `Direct` when navigation is disabled. Complete and Partial
describe the planned route. `first_waypoint`, `expansions`, and `clipping` retain search and commit
Gate evidence. `endpoint == from` means no movement was approved, even if a complete path was
planned. A blocked search holds position. Creature callers retain `nav_step` and its existing
collision-gated fallback.

`coverage` is `Unknown` unless every consulted navigation cell matches the active complete
manifest. `VerifiedCells` names that generation and the number of checked cells. It proves only
those cells' derivation, not world coverage or the presence of imported terrain and client
geometry. A Package must measure actual position on later observations to establish advancement
or arrival; the proposed endpoint cannot establish either.
6 changes: 6 additions & 0 deletions module/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ const HOOK_EVENTS: &[HookEvent] = &[
"payload.caster_guid",
"payload.target_guid",
),
hook(
"on_cast_finished",
"crate::hooks::CastFinishedPayload",
"payload.caster_guid",
"payload.target_guid",
),
// The corpse is a loot container, not a live entity, so `event.target` is normally absent here.
hook(
"on_loot",
Expand Down
Loading