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
61 changes: 53 additions & 8 deletions importer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,14 +466,10 @@ mod qt {
pub const LIMIT_TIME: usize = 19;
pub const SPECIAL_FLAGS: usize = 21; // bit0 = QUEST_SPECIAL_FLAGS_REPEATABLE
pub const PREV_QUEST_ID: usize = 22; // prerequisite quest (must be turned in first); 0 = none
// [V] NextQuestId/NextQuestInChain (the successor auto-offered on turn-in) — unverified column
// index (no cmangos dump in this sandbox to confirm against). Bracketed between the two
// already-trusted constants either side: 3 slots after PREV_QUEST_ID=22 (PrevQuestId, NextQuestId,
// ExclusiveGroup sit between), 2 slots before SRC_ITEM_ID=27. Coverage-printed at import time
// (chained-quest count) + a presence floor (`FLOOR_QUESTS_CHAINED`, importer/scripts/import-manifest.sh) —
// McBride's 783->7->15->21->54 chain (already verified via the working `prev_quest_id` import)
// should populate this symmetrically, so >=1 is a safe floor, unlike LIMIT_TIME above.
pub const NEXT_QUEST_IN_CHAIN: usize = 25;
// ClassicDB names columns 23 through 26 NextQuestId, ExclusiveGroup, BreadcrumbForQuestId,
// and NextQuestInChain. The forward chain is column 26. Quest 783 carries 0 at column 25 and 7
// at column 26, so using the breadcrumb column silently loses its link to quest 7.
pub const NEXT_QUEST_IN_CHAIN: usize = 26;
pub const SRC_ITEM_ID: usize = 27; // SrcItemId — item HANDED to the player on accept (provided item)
pub const SRC_ITEM_COUNT: usize = 28; // SrcItemCount (0 in the dump means 1)
pub const TITLE: usize = 30;
Expand Down Expand Up @@ -8285,6 +8281,28 @@ mod tests {
format!("({})", cols.join(","))
}

/// The leading fields of ClassicDB's real quest 783 row. Keep the two adjacent zero and seven
/// values literal so this catches a column constant that drifts back to BreadcrumbForQuestId.
fn classicdb_quest_783_row() -> String {
let mut cols = vec!["0".to_string(); 123];
for (index, value) in [
(0, "783"),
(1, "2"),
(2, "9"),
(3, "1"),
(4, "255"),
(5, "1"),
(8, "77"),
(20, "8"),
(25, "0"),
(26, "7"),
(30, "'A Threat Within'"),
] {
cols[index] = value.to_string();
}
format!("({})", cols.join(","))
}

/// A tiny but REAL synthetic dump: one in-box creature (a quest giver) + its template + one
/// quest + the giver relation linking them — just enough for `build_dump_plan` to run
/// end-to-end (every family's prerequisite parsing touches this data) without a real cmangos
Expand Down Expand Up @@ -8945,6 +8963,33 @@ mod tests {
assert!(insert.contains(",501,3600,777"), "next_quest_id=501, limit_time=3600, reward_money_max_level=777 must thread through, in order, as END-appended columns: {insert}");
}

#[test]
fn classicdb_quest_783_uses_next_quest_in_chain_after_breadcrumb() {
let dump = format!(
"INSERT INTO `creature` VALUES (1,823,0,1,-8933.54,-136.523,83.4466,0,300,300,0,0); \
INSERT INTO `creature_template` VALUES {}; \
INSERT INTO `quest_template` VALUES {}; \
INSERT INTO `creature_questrelation` VALUES (823,783);",
creature_template_row(823, 0),
classicdb_quest_783_row(),
);
let mut args = test_args();
args.family = Some("quests".to_string());
let plan = build_dump_plan(&dump, &args, &None, &None).expect("quest import plan");
let insert = plan
.stmts
.iter()
.find(|statement| {
statement.starts_with("INSERT INTO game_quest_template")
&& statement.contains("(783,")
})
.expect("quest 783 insert");
assert!(
insert.contains(",false,7,0,"),
"quest 783 must retain its forward link to quest 7: {insert}"
);
}

#[test]
fn insert_row_count_counts_values_tuples_not_statements() {
// The provenance-stamp fix (216 review finding): a 3-row batched INSERT is 3 rows, a DELETE
Expand Down
36 changes: 31 additions & 5 deletions module/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
//! | `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) |
//! | `turn_in_quest` | `quest::apply_turn_in_quest` | alive + giver in range ending the quest + objectives complete; rewards atomic |
//! | `open_creature_loot` | `loot::open_creature_corpse` | alive + dead creature corpse on the same map within 10yd + Loot Tag eligibility; authorizes the following read |
//! | `take_loot` | `items::apply_take_loot` | alive + corpse/GO on same map within 10yd + Loot Tag eligibility for creatures + slot occupied; inventory-full rolls back |
//! | `open_creature_loot` | `loot::open_creature_corpse` | legacy result adapter used by the Gateway |
//! | `request_open_creature_loot` | `loot::request_open_creature_corpse` | typed open acceptance or Refusal; applies the same corpse and Loot Tag Gates |
//! | `take_loot` | `items::apply_take_loot` | legacy result adapter used by the existing rest-and-loot goal |
//! | `request_take_loot` | `items::request_take_loot` | typed take completion or Refusal; inventory-full leaves the item and Loot Source unchanged |
//! | `loot_money` | `loot::apply_loot_money` | alive + dead creature corpse, same map, 10yd, money > 0 + Loot Tag eligibility |
//! | `buy_item` | `items::apply_buy_item` | vendor in range + stocked + money; stacks/slots validated |
//! | `sell_item` | `items::apply_item_sell` | vendor in range + sellable item in slot; feeds the buyback ring |
Expand All @@ -35,6 +37,8 @@
//! | `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 |
//! | `request_use_gameobject` | `gameobject::request_use_gameobject` | typed target, partition, range, and use Refusal |
//! | `import_revision` | `game_import_meta` read | current importer source and file identities for one family |
//! | `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 % |
//! | `spirit_res` | `world::do_spirit_healer_res` | ghost actor res at the spirit healer (sickness applies) |
Expand All @@ -52,8 +56,27 @@
//! `packages/` drop-ins, discovered by `module/build.rs`). Both silence unused-import ONLY in the
//! build where the consumer isn't compiled, and neither is a licence to keep a verb no tree calls.

use crate::import_meta::game_import_meta;
use spacetimedb::ReducerContext;

#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) struct ImportRevision {
pub source_sha: String,
pub file_hash: String,
}

#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn import_revision(ctx: &ReducerContext, family: &str) -> Option<ImportRevision> {
ctx.db
.game_import_meta()
.family()
.find(family.to_string())
.map(|meta| ImportRevision {
source_sha: meta.source_sha,
file_hash: meta.file_hash,
})
}

// ---- combat ----

// A `debug_only!` verb's sole consumer today is the feature-gated harness; a default build
Expand Down Expand Up @@ -126,12 +149,14 @@ debug_only! { pub(crate) use crate::quest::grant_quest_unchecked as stage_quest;

// ---- loot / inventory / vendor ----

pub(crate) use crate::loot::open_creature_corpse as open_creature_loot;
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::loot::request_open_creature_corpse as request_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;
pub(crate) use crate::items::request_take_loot as request_take_loot;
// `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 @@ -150,10 +175,11 @@ package_only! {

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

debug_only! {
package_only! {
pub(crate) use crate::gameobject::apply_use_gameobject as use_gameobject;
pub(crate) use crate::trainer::apply_trainer_buy as trainer_buy;
pub(crate) use crate::gameobject::request_use_gameobject as request_use_gameobject;
}
debug_only! { pub(crate) use crate::trainer::apply_trainer_buy as trainer_buy; }
package_only! {
pub(crate) use crate::spell::do_resurrect_response as respond_resurrect;
pub(crate) use crate::world::do_repop as repop;
Expand Down
21 changes: 20 additions & 1 deletion module/src/creatures/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,8 @@ pub(crate) fn timer_never(ctx: &spacetimedb::ReducerContext) -> spacetimedb::Tim
// `game_aura`'s `by_expiry`, and `gateway/tests/schema_parity.rs` checks columns/bindings, not
// indexes, so the gateway is unaffected).
index(accessor = by_respawn_at, btree(columns = [respawn_at])),
index(accessor = by_despawn_at, btree(columns = [despawn_at]))
index(accessor = by_despawn_at, btree(columns = [despawn_at])),
index(accessor = by_entry_map, btree(columns = [entry, map_id]))
)]
pub struct CreatureSpawn {
#[primary_key]
Expand Down Expand Up @@ -562,6 +563,24 @@ pub struct CreatureSpawn {
pub life_seq: u64,
}

/// Read stored spawn evidence for one creature entry without walking the world spawn table.
/// Callers still inspect `game_world_entity` when they need a live target they can act on now.
#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn creature_spawn_evidence(
ctx: &ReducerContext,
entry: u32,
map_id: u32,
limit: usize,
) -> Vec<CreatureSpawn> {
const MAX_RESULTS: usize = 128;
ctx.db
.game_creature_spawn()
.by_entry_map()
.filter((entry, map_id))
.take(limit.min(MAX_RESULTS))
.collect()
}

/// Which life of `creature_guid` is standing right now.
///
/// A creature keeps its guid across death and respawn, so a durable row keyed on the guid needs this
Expand Down
85 changes: 72 additions & 13 deletions module/src/gameobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ pub struct GameObjectUnlocked {
// sentinel on every live row, so a `1..=now` range visits only DUE nodes — the same trick
// `game_aura.by_next_tick` uses.
index(accessor = by_respawn_at, btree(columns = [respawn_at_micros])),
index(
accessor = by_template_scope,
btree(columns = [template_entry, map_id, instance_id])
),
// The AOI cell index — exactly 3 columns, all matched by equality terms, which is the
// only shape SpacetimeDB 2.7.1's subscription planner can serve (see the `cell` column).
index(accessor = by_cell, btree(columns = [map_id, instance_id, cell]))
Expand Down Expand Up @@ -287,6 +291,26 @@ pub struct GameObject {
pub rotation_3: f32,
}

/// Read stored GameObject destination evidence through an exact template and partition index.
/// State and distance decide whether a returned object can be used now. They do not erase the
/// stored destination when every matching object is depleted or far away.
#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn gameobject_destination_evidence(
ctx: &ReducerContext,
template_entry: u32,
map_id: u32,
instance_id: u64,
limit: usize,
) -> Vec<GameObject> {
const MAX_RESULTS: usize = 128;
ctx.db
.game_gameobject()
.by_template_scope()
.filter((template_entry, map_id, instance_id))
.take(limit.min(MAX_RESULTS))
.collect()
}

// ===========================================================================================
// Gathering POOL model (cmangos pool_template + pool_gameobject) — MODEL A (delete/insert)
// ===========================================================================================
Expand Down Expand Up @@ -643,39 +667,55 @@ fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool {
.is_none()
}

/// The GO target-acquisition gate shared by [`apply_pick_lock`] and [`apply_use_gameobject`]:
/// resolve the GameObject + its template, resolve the caster's live entity, and confirm they
/// share a map+instance and are within [`USE_RANGE_SQ`]. Every error string below is preserved verbatim
/// from both call sites (the two ~30-line blocks were byte-identical before this extraction).
/// The typed GameObject target Gate shared by lock picking and both use adapters. It resolves the
/// GameObject, template and live Character, then applies partition and range Gates once.
fn usable_go(
ctx: &ReducerContext,
caster_guid: u64,
go_guid: u64,
) -> Result<(GameObject, GameObjectTemplate), String> {
) -> Result<(GameObject, GameObjectTemplate), crate::actor::ActionRefusal> {
use crate::actor::{ActionRefusal, ActionRefusalKind};

let go = ctx
.db
.game_gameobject()
.guid()
.find(go_guid)
.ok_or_else(|| "no such gameobject".to_string())?;
.ok_or_else(|| {
ActionRefusal::new(ActionRefusalKind::MissingTarget, "no such gameobject")
})?;
let player = crate::helpers::live_entity(ctx, caster_guid)
.map_err(|_| "user not in world".to_string())?;
.map_err(|_| ActionRefusal::new(ActionRefusalKind::MissingActor, "user not in world"))?;
if player.map_id != go.map_id {
return Err("gameobject on another map".to_string());
return Err(ActionRefusal::new(
ActionRefusalKind::OtherPartition,
"gameobject on another map",
));
}
if player.instance_id != go.instance_id {
return Err("gameobject in another instance".to_string());
return Err(ActionRefusal::new(
ActionRefusalKind::OtherPartition,
"gameobject in another instance",
));
}
let (dx, dy, dz) = (go.x - player.x, go.y - player.y, go.z - player.z);
if dx * dx + dy * dy + dz * dz > USE_RANGE_SQ {
return Err("gameobject out of range".to_string());
return Err(ActionRefusal::new(
ActionRefusalKind::OutOfRange,
"gameobject out of range",
));
}
let tmpl = ctx
.db
.game_gameobject_template()
.entry()
.find(go.template_entry)
.ok_or_else(|| format!("no gameobject template {}", go.template_entry))?;
.ok_or_else(|| {
ActionRefusal::new(
ActionRefusalKind::MissingTarget,
format!("no gameobject template {}", go.template_entry),
)
})?;
Ok((go, tmpl))
}

Expand All @@ -692,7 +732,7 @@ pub(crate) fn apply_pick_lock(
caster_guid: u64,
go_guid: u64,
) -> Result<(), String> {
let (_go, tmpl) = usable_go(ctx, caster_guid, go_guid)?;
let (_go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?;
if tmpl.lock_id == 0 {
return Err("it is not locked".to_string());
}
Expand Down Expand Up @@ -753,6 +793,16 @@ pub(crate) fn apply_pick_lock(
/// Shared use-a-gameobject core for Character and debug paths. [`usable_go`] requires a live
/// Character, a loaded GameObject template, the same map and instance, and use range before
/// type-specific effects run. Supported lock-bearing types also apply [`locked_shut`] first. [entity]
#[cfg_attr(not(has_packages), allow(dead_code))]
pub(crate) fn request_use_gameobject(
ctx: &ReducerContext,
caster_guid: u64,
go_guid: u64,
) -> Result<(), crate::actor::ActionRefusal> {
let (go, tmpl) = usable_go(ctx, caster_guid, go_guid)?;
use_resolved_gameobject(ctx, caster_guid, go, tmpl).map_err(Into::into)
}

pub(crate) fn apply_use_gameobject(
ctx: &ReducerContext,
caster_guid: u64,
Expand All @@ -761,7 +811,16 @@ pub(crate) fn apply_use_gameobject(
// Map + instance gated (190 slice 2 — GO rows carry `instance_id` now): a player may only use
// a GO in THEIR OWN instance (dungeon doors/chests are per-instance copies; the static
// instance-0 originals on a dungeon map are the copy SOURCES, unreachable from inside a run).
let (mut go, tmpl) = usable_go(ctx, caster_guid, go_guid)?;
let (go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?;
use_resolved_gameobject(ctx, caster_guid, go, tmpl)
}

fn use_resolved_gameobject(
ctx: &ReducerContext,
caster_guid: u64,
mut go: GameObject,
tmpl: GameObjectTemplate,
) -> Result<(), String> {
// Snapshot for the on_go_used notify hook fired at the success exit below — `go` is moved into
// an update (or deleted by a pool reroll) inside the dispatch arms. The instance is the GO
// ROW'S OWN (190 slice 2, per 228's documented splice note; equal to the user's after the gate
Expand Down
Loading