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
58 changes: 29 additions & 29 deletions module/src/gameobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
//! `game_corpse_loot` table KEYED ON THE GO GUID (so the existing corpse loot window + take path serve
//! it unchanged) — falling back to the legacy single `data0` drop when the chest has no data-driven
//! table (the seed/demo chest) — a GOOBER grants quest credit for a USE_GAMEOBJECT objective, a
//! DOOR/BUTTON toggles open/closed (work-item 211). Every OTHER in-scope cmangos GO type imports
//! DOOR/BUTTON toggles open/closed. Every OTHER in-scope cmangos GO type imports
//! (template + spawn) but is INERT — `use` is a benign no-op, not an error (see `go_type::*` +
//! `apply_use_gameobject`'s catch-all arm). LOCK enforcement (`game_lock`, Lock.dbc — work-item 211's
//! data half) is DEFERRED to work-item 119: a locked door/chest/goober opens freely today; `lock_id`
//! is carried on the template but nothing reads it yet. New tables → additive auto-migration. [entity]
//! `apply_use_gameobject`'s catch-all arm). A lock with a real opener blocks CHEST, GOOBER,
//! DOOR and BUTTON use until `apply_pick_lock` records it as unlocked. New tables use additive
//! auto-migration. [entity]

use std::collections::HashSet;

Expand All @@ -25,10 +25,9 @@ use crate::terrain::game_terrain_chunk;
/// The gameobject types this slice handles (cmangos `GAMEOBJECT_TYPE_*`). Deliberate
/// simplification: only these two plus the synthetic GATHER marker.
pub mod go_type {
/// Door prop — `use` toggles open/closed (state 0↔1 via [`super::toggle_state`]), byte-identical
/// shape to the BUTTON toggle below. LOCK enforcement (`game_lock`) is DATA-ONLY this slice
/// (work-item 211) — a locked door opens/closes freely until work-item 119 wires the gate into
/// this dispatch. autoCloseTime (cmangos data2) is NOT modeled — a toggled-open door stays open
/// Door prop. `use` toggles open/closed (state 0↔1 via [`super::toggle_state`]), byte-identical
/// shape to the BUTTON toggle below. A lock with a real opener must be opened first.
/// autoCloseTime (cmangos data2) is NOT modeled — a toggled-open door stays open
/// until toggled again (no auto-close timer this slice). cmangos type 0 (GAMEOBJECT_TYPE_DOOR).
pub const DOOR: u8 = 0;
/// Button/lever prop — cmangos models a lever as a differently-DISPLAYED door; the toggle
Expand All @@ -46,7 +45,8 @@ pub mod go_type {
pub const CHEST: u8 = lyracore_shared::constants::go_type::CHEST;
/// Spell trap. Relay activation uses its imported spell and cooldown metadata.
pub const TRAP: u8 = 6;
/// Quest-use object (lever/totem/etc.) — `use` grants USE_GAMEOBJECT quest credit. cmangos type 10.
/// Quest-use object such as a lever or totem. Unlocked `use` grants USE_GAMEOBJECT quest credit.
/// This is cmangos type 10.
pub const GOOBER: u8 = 10;
/// Gather node (mining vein / herb bush). `use` skill-gates then DIRECT-grants the ore/herb
/// (skinning-style, NOT the loot window — the gtker loot-window codec is incomplete). cmangos models
Expand Down Expand Up @@ -100,14 +100,13 @@ pub struct GameObjectTemplate {
// The "orange" floor is the node's existing `data1` (required skill). Real tables (gray>0) are DEFERRED.
#[default(0u32)]
pub gather_gray: u32,
// END-APPENDED (work-item 211, Option A — additive auto-migration, same discipline as the columns
// above). The cmangos lockId this template carries, hand-synced positionally into the gateway's
// END-APPENDED. Uses additive auto-migration, with the same discipline as the columns above.
// The cmangos lockId is hand-synced positionally into the gateway's
// `game_object_template_type.rs` binding (decode-only — the gateway never READS this field, only
// decodes past it). CHEST/GOOBER source it from the dump's `data0`; DOOR/BUTTON from `data1` (see
// `importer/src/dbc.rs`'s `go_template_row`). DATA-ONLY this slice: `game_lock` (below) holds the
// Lock.dbc requirements for this id, but NOTHING enforces them yet — work-item 119 wires the gate
// into `apply_use_gameobject`. 0 = unlocked / not carried this slice (every existing row,
// default-migrated, and any type this slice doesn't source a lockId for).
// `importer/src/dbc.rs`'s `go_template_row`). `game_lock` holds the Lock.dbc requirements for this
// id. A real opener gates CHEST, GOOBER, DOOR and BUTTON use. 0 = unlocked or not carried for this
// type. Existing rows default to 0.
#[default(0u32)]
pub lock_id: u32,
// END-APPENDED. The cmangos `gameobject_template.size` the client renders this prop at
Expand Down Expand Up @@ -140,10 +139,9 @@ pub struct GameObjectTrapCooldown {
pub ready_at: Timestamp,
}

/// Lock.dbc → `game_lock` (work-item 211: the DATA half of open-lock; work-item 119 is the system half
/// that actually GATES `apply_use_gameobject` on this table — nothing reads `game_lock` yet). One
/// Lock.dbc row packs up to 8 PARALLEL alternative ways to open it (e.g. "either the right key OR
/// enough Lockpicking OR..."); each non-`LockType::None` alternative un-packs into its own row here,
/// Lock.dbc data in `game_lock`. `apply_use_gameobject` reads this table to gate supported locked
/// types. One Lock.dbc row packs up to 8 PARALLEL alternative ways to open it, such as the right key
/// or enough Lockpicking. Each non-`LockType::None` alternative un-packs into its own row here,
/// `index`-numbered 0..8 (see `importer/src/dbc.rs::lock_sql` — a lock with 1 real requirement emits
/// exactly 1 row). Look up by [`GameObjectTemplate::lock_id`] via `by_lock`. `kind`: 1 = ITEM (open
/// with the key item `property` names) — 2 = SKILL (need SkillLine `property` at >= `required_skill`;
Expand Down Expand Up @@ -180,8 +178,8 @@ pub(crate) fn is_real_lock_opener(kind: u8, property: u32) -> bool {
(kind == LOCK_KIND_SKILL && property != 0) || kind == LOCK_KIND_ITEM
}

/// A GameObject whose lock has been PICKED open (work-item 119): the CHEST/DOOR use-gate reads this by
/// GO guid to admit the loot/toggle a still-locked GO refuses. Module-only + UNSUBSCRIBED (no gateway
/// A GameObject whose lock has been PICKED open. The CHEST/GOOBER/DOOR use-gate reads
/// this by GO guid to admit a use that a lock would refuse. Module-only + UNSUBSCRIBED (no gateway
/// binding files, no gateway subscription — the `game_lock` precedent above); `public` + no Timestamp so
/// a verify can inspect it via `spacetime sql`. One row per unlocked GO guid; never re-locked this slice
/// (matches vanilla — a picked chest stays open). A row for a despawned GO is a harmless orphan (a live
Expand Down Expand Up @@ -618,8 +616,8 @@ pub(crate) fn respawn_due(state: u8, respawn_at_micros: u64, now: u64) -> bool {
state == 1 && respawn_at_micros != 0 && now >= respawn_at_micros
}

/// Does the lock `lock_id` REQUIRE opening before a CHEST/DOOR use (work-item 119)? True iff `game_lock`
/// holds a REAL opener row for it (`is_real_lock_opener` — a skill line to meet, or a key item). A lock
/// Does the lock `lock_id` require opening before a CHEST/GOOBER/DOOR use? True iff `game_lock`
/// holds a REAL opener row for it, either a skill line to meet or a key item. A lock
/// with ONLY degenerate `property==0` slots (unmapped LockTypes = open-by-hand) is NOT gated: it opened
/// freely pre-119 and still does, so widening the import's lock coverage never bricks a hand-open chest.
fn lock_requires_opening(ctx: &ReducerContext, lock_id: u32) -> bool {
Expand All @@ -630,9 +628,9 @@ fn lock_requires_opening(ctx: &ReducerContext, lock_id: u32) -> bool {
.any(|r| is_real_lock_opener(r.kind, r.property))
}

/// Is the GO `go_guid` (template `lock_id`) LOCKED SHUT for the use-gate (work-item 119)? True iff it
/// Is the GO `go_guid` with template `lock_id` locked shut for the use-gate? True iff it
/// carries a lock_id with a real opener AND no `game_gameobject_unlocked` row exists yet. The shared
/// gate for the CHEST + DOOR/BUTTON arms only (GATHER/QUESTGIVER carry lock_id 0 → never gated).
/// gate for the CHEST, GOOBER and DOOR/BUTTON arms. GATHER and QUESTGIVER carry lock_id 0.
fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool {
lock_id != 0
&& lock_requires_opening(ctx, lock_id)
Expand Down Expand Up @@ -751,10 +749,9 @@ pub(crate) fn apply_pick_lock(
Ok(())
}

/// Shared use-a-gameobject core (player + debug paths). Gates same-map + range, then dispatches by
/// template type. CHEST → roll its single `data0` drop into `game_corpse_loot` keyed on the GO guid,
/// ONCE (state flips 0→1 so a re-use can't re-roll), so the existing loot window/take path serves it.
/// GOOBER → grant USE_GAMEOBJECT quest credit. Other types are not usable yet. [entity]
/// 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]
pub(crate) fn apply_use_gameobject(
ctx: &ReducerContext,
caster_guid: u64,
Expand Down Expand Up @@ -837,6 +834,9 @@ pub(crate) fn apply_use_gameobject(
ctx.db.game_gameobject().guid().update(go);
}
go_type::GOOBER => {
if locked_shut(ctx, go.guid, tmpl.lock_id) {
return Err("it is locked".to_string());
}
crate::quest::on_gameobject_used(ctx, caster_guid, go.template_entry);
}
go_type::GATHER => {
Expand Down
226 changes: 226 additions & 0 deletions module/tests/goober_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! Locked GOOBER use through the Module boundary.

mod support;

use support::Standalone;

const CHARACTER_GUID: u64 = 1;
const QUEST_ENTRY: u32 = 50_900;
const QUEST_OBJECTIVE_ID: u64 = 5_090_000;
const LOCKED_GOOBER_ENTRY: u32 = 5_093_670;
const LOCKLESS_GOOBER_ENTRY: u32 = 5_093_672;
const LOCK_ID: u32 = 5_093_671;
const KEY_ITEM_ENTRY: u32 = 5_090_052;
const SCRIPT_HASH: &str = "3673673673673673673673673673673673673673673673673673673673673673";

fn json_arg(value: &str) -> String {
serde_json::to_string(value).expect("a string encodes as JSON")
}

fn install_go_used_hook(standalone: &Standalone) {
let artifact = serde_json::json!({
"kind": "script",
"version": 1,
"package": "fixture.goober-lock",
"source_hash": SCRIPT_HASH,
"scripts": [{
"script_id": 367_367,
"name": "goober-lock.used",
"event": "on_go_used",
"priority": 0,
"enabled": true,
"source": "grant_xp(event.actor, 7)",
}],
})
.to_string();
standalone.assert_call(
"apply_package_deltas",
&[&json_arg("script"), &json_arg(&artifact)],
);
}

fn spawn_goober(standalone: &Standalone, entry: u32) {
let args = [
entry.to_string(),
"10".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"100".to_string(),
"100".to_string(),
"100".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
];
standalone.assert_call(
"debug_spawn_gameobject",
&args.iter().map(String::as_str).collect::<Vec<_>>(),
);
}

fn quest_count(standalone: &Standalone) -> u32 {
let rows = standalone.query_rows(&format!(
"SELECT counts FROM game_character_quest WHERE character_guid = {CHARACTER_GUID} AND quest_entry = {QUEST_ENTRY}"
));
let counts = rows.first().expect("the quest is in the log")["counts"]
.trim_matches(['[', ']'])
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.parse::<u32>().expect("quest count is a number"))
.collect::<Vec<_>>();
counts[0]
}

fn xp(standalone: &Standalone) -> u32 {
standalone
.query_rows(&format!(
"SELECT xp FROM game_world_entity WHERE guid = {CHARACTER_GUID}"
))
.first()
.expect("the Character is live")["xp"]
.parse()
.expect("xp is a number")
}

fn goober_state(standalone: &Standalone, entry: u32) -> u8 {
standalone
.query_rows(&format!(
"SELECT state FROM game_gameobject WHERE template_entry = {entry}"
))
.first()
.expect("the GOOBER is spawned")["state"]
.parse()
.expect("state is a number")
}

fn unlocked_count(standalone: &Standalone) -> usize {
standalone
.query_rows("SELECT go_guid FROM game_gameobject_unlocked")
.len()
}

fn key_count(standalone: &Standalone) -> u32 {
standalone
.query_rows(&format!(
"SELECT stack_count FROM game_item_instance WHERE owner_guid = {CHARACTER_GUID} AND entry = {KEY_ITEM_ENTRY}"
))
.iter()
.map(|row| {
row["stack_count"]
.parse::<u32>()
.expect("stack count is a number")
})
.sum()
}

/// Runs only when requested because it builds and publishes the Module to a private Standalone.
#[test]
#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"]
fn locked_goober_use_is_atomic_until_an_opener_unlocks_it() {
let mut standalone = Standalone::start("goober-lock");
standalone.publish_module();
standalone.assert_call("claim_operator", &[]);
standalone.assert_call("debug_seed_scenario_fixtures", &[]);
standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER_GUID.to_string()]);
standalone.assert_sql(&format!(
"UPDATE game_world_entity SET x = 100, y = 100, z = 100 WHERE guid = {CHARACTER_GUID}"
));

spawn_goober(&standalone, LOCKED_GOOBER_ENTRY);
standalone.assert_sql(&format!(
"UPDATE game_gameobject_template SET lock_id = {LOCK_ID} WHERE entry = {LOCKED_GOOBER_ENTRY}"
));
standalone.assert_sql(&format!(
"INSERT INTO game_lock (id, lock_id, index, kind, property, required_skill) VALUES (5093671, {LOCK_ID}, 0, 1, {KEY_ITEM_ENTRY}, 0)"
));
standalone.assert_sql(&format!(
"UPDATE game_quest_objective SET kind = 2, target_entry = {LOCKED_GOOBER_ENTRY}, required_count = 2 WHERE id = {QUEST_OBJECTIVE_ID}"
));
standalone.assert_call(
"debug_grant_quest",
&[&CHARACTER_GUID.to_string(), &QUEST_ENTRY.to_string()],
);
install_go_used_hook(&standalone);

let before_quest = quest_count(&standalone);
let before_xp = xp(&standalone);
let before_state = goober_state(&standalone, LOCKED_GOOBER_ENTRY);
let refused = standalone.call(
"debug_use_gameobject_entry",
&[
&CHARACTER_GUID.to_string(),
&LOCKED_GOOBER_ENTRY.to_string(),
],
);
let refused_output = format!(
"{}{}",
String::from_utf8_lossy(&refused.stdout),
String::from_utf8_lossy(&refused.stderr)
);
assert!(
!refused.status.success() && refused_output.contains("it is locked"),
"locked use should be refused before any effect:\n{refused_output}"
);
assert_eq!(quest_count(&standalone), before_quest);
assert_eq!(xp(&standalone), before_xp);
assert_eq!(goober_state(&standalone, LOCKED_GOOBER_ENTRY), before_state);
assert_eq!(unlocked_count(&standalone), 0);

let pick_without_key = standalone.call(
"debug_pick_lock_entry",
&[
&CHARACTER_GUID.to_string(),
&LOCKED_GOOBER_ENTRY.to_string(),
],
);
assert!(!pick_without_key.status.success());
assert_eq!(unlocked_count(&standalone), 0);

standalone.assert_call(
"debug_grant_item",
&[
&CHARACTER_GUID.to_string(),
&KEY_ITEM_ENTRY.to_string(),
"1",
],
);
assert_eq!(key_count(&standalone), 1);
standalone.assert_call(
"debug_pick_lock_entry",
&[
&CHARACTER_GUID.to_string(),
&LOCKED_GOOBER_ENTRY.to_string(),
],
);
assert_eq!(unlocked_count(&standalone), 1);
assert_eq!(key_count(&standalone), 1, "OPEN_LOCK does not consume keys");

standalone.assert_call(
"debug_use_gameobject_entry",
&[
&CHARACTER_GUID.to_string(),
&LOCKED_GOOBER_ENTRY.to_string(),
],
);
assert_eq!(quest_count(&standalone), before_quest + 1);
assert_eq!(xp(&standalone), before_xp + 7);
assert_eq!(goober_state(&standalone, LOCKED_GOOBER_ENTRY), before_state);

spawn_goober(&standalone, LOCKLESS_GOOBER_ENTRY);
standalone.assert_sql(&format!(
"UPDATE game_quest_objective SET target_entry = {LOCKLESS_GOOBER_ENTRY} WHERE id = {QUEST_OBJECTIVE_ID}"
));
standalone.assert_call(
"debug_use_gameobject_entry",
&[
&CHARACTER_GUID.to_string(),
&LOCKLESS_GOOBER_ENTRY.to_string(),
],
);
assert_eq!(quest_count(&standalone), before_quest + 2);
assert_eq!(xp(&standalone), before_xp + 14);
assert_eq!(goober_state(&standalone, LOCKLESS_GOOBER_ENTRY), 0);
}