From cddfab181b1c45ae6af51ac0c05c3f9ba6244b33 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 15:45:10 +0000 Subject: [PATCH 1/8] feat(items): carry per-instance random properties through item storage --- CONTEXT.md | 67 +++ crates/lyracore-shared/src/item_property.rs | 33 ++ crates/lyracore-shared/src/lib.rs | 2 + crates/lyracore-shared/src/loot_roll.rs | 87 ++- crates/lyracore-shared/src/trade.rs | 11 +- gateway/src/codec/auction.rs | 6 +- gateway/src/codec/entity.rs | 5 +- gateway/src/codec/item.rs | 8 +- gateway/src/codec/loot.rs | 15 +- gateway/src/codec/mail.rs | 3 +- gateway/src/codec/tests.rs | 55 +- gateway/src/codec/values.rs | 4 +- gateway/src/stdb/armor.rs | 39 +- gateway/src/stdb/connection.rs | 2 + gateway/src/stdb/reads/auction.rs | 2 + gateway/src/stdb/reads/items.rs | 13 +- gateway/src/stdb/reads/mail.rs | 2 + gateway/src/stdb/reads/party.rs | 1 + gateway/src/stdb/reducers.rs | 13 +- gateway/src/stdb/subscriptions.rs | 82 ++- gateway/src/stdb/views.rs | 1 + gateway/src/stdb/world_store.rs | 2 + gateway/src/world/handlers/auction.rs | 3 + gateway/src/world/handlers/char.rs | 5 +- gateway/src/world/handlers/vendor.rs | 32 +- gateway/src/world/loot.rs | 2 + gateway/src/world/loot_tests.rs | 4 + gateway/src/world/mail.rs | 2 + gateway/src/world/mail_tests.rs | 2 + gateway/src/world/store.rs | 2 + gateway/src/world/tests.rs | 17 +- importer/src/dbc.rs | 2 + importer/src/item_property.rs | 498 ++++++++++++++++++ importer/src/main.rs | 17 +- module/src/auction.rs | 22 + module/src/debug/mod.rs | 9 + module/src/debug/repair.rs | 1 + module/src/gameobject.rs | 2 + module/src/items/economy.rs | 4 +- module/src/items/inventory.rs | 4 +- module/src/items/mod.rs | 2 + module/src/items/ops.rs | 122 +++-- module/src/items/properties.rs | 224 ++++++++ module/src/items/rules.rs | 90 +--- module/src/items/tables.rs | 14 +- module/src/loot/mod.rs | 16 + module/src/loot/rolls.rs | 46 +- module/src/loot/tag.rs | 3 + module/src/mail.rs | 5 + module/src/mail_escrow.rs | 14 +- module/src/mail_escrow/harness.rs | 3 + module/src/package_import/items.rs | 1 + module/src/professions.rs | 2 +- module/src/seed.rs | 1 + module/src/seed/fixtures.rs | 1 + module/src/trade.rs | 3 + module/src/transfer/legacy_item_rows.rs | 175 ++++++ module/src/transfer/legacy_item_rows_tests.rs | 251 +++++++++ module/src/transfer/transport.rs | 28 +- module/tests/loot_roll_promotion.rs | 3 + module/tests/random_properties.rs | 158 ++++++ 61 files changed, 1978 insertions(+), 265 deletions(-) create mode 100644 crates/lyracore-shared/src/item_property.rs create mode 100644 importer/src/item_property.rs create mode 100644 module/src/items/properties.rs create mode 100644 module/src/transfer/legacy_item_rows.rs create mode 100644 module/src/transfer/legacy_item_rows_tests.rs create mode 100644 module/tests/random_properties.rs diff --git a/CONTEXT.md b/CONTEXT.md index 3988dde71..a9bad2979 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -763,3 +763,70 @@ An agent-sized slice of an issue: one context window of work, kept local. **Tracer**: The first Ticket of an issue. Establishes the Seam or pattern the other Tickets copy, and blocks them. + +### Random properties + +**Random Property**: +The suffix selected for one item instance at creation, held as `random_property_id`. Zero is a +plain item. Moving an existing item preserves this value, including zero. +_Avoid_: random enchant, item variant, roll + +**Property Pool**: +The weighted set of Random Properties a template can produce, named by +`game_item_template.random_property`. Members sort by property ID. The importer stores ClassicDB +chance in exact hundredths and refuses more than two decimal places. Selection compares +`input * total_weight < cumulative_weight * 10000` using widened integer arithmetic, for inputs +0 through 9999. Thus each cumulative band endpoint rounds up. Both underweight and overweight +pools use their full total. Very small weights can receive no input at this resolution. +_Avoid_: random property group, suffix group + +**Suffix**: +The name a Random Property adds to an item, such as "of the Bear". The Module keeps a copy for +diagnosis. The client renders its own. +_Avoid_: affix, postfix + +**Stat Kind**: +An append-only code for an enchantment effect's contribution, independent of the client encoding. +The importer retains unmapped effects as kind 0, which contributes nothing. Amounts are flat +points, except crit, hit, dodge, parry and block, which use basis points, and regeneration, which +uses points per five seconds. Spell power retains a school mask. +_Avoid_: stat type, mod type, effect type + +| Code | Contribution | +| --- | --- | +| 0 | Unknown | +| 1 | Strength | +| 2 | Agility | +| 3 | Stamina | +| 4 | Intellect | +| 5 | Spirit | +| 6 | Health | +| 7 | Mana | +| 8 | Holy resistance | +| 9 | Fire resistance | +| 10 | Nature resistance | +| 11 | Frost resistance | +| 12 | Shadow resistance | +| 13 | Arcane resistance | +| 14 | Armor | +| 15 | Weapon damage | +| 16 | Spell power | +| 17 | Healing power | +| 18 | Mana per five seconds | +| 19 | Health per five seconds | +| 20 | Crit | +| 21 | Hit | +| 22 | Defense | +| 23 | Dodge | +| 24 | Parry | +| 25 | Block | + +Each enchantment effect has key `(u64(enchant_id) << 8) | effect_index`. Indices reserve 64 positions +per client enchantment slot. A referenced spell's three subeffects each reserve eight positions +for expanded stats or schools. The index is `slot * 64 + subeffect * 8 + expansion`, from 0 through +191. Direct effects use subeffect zero and expansion zero. `spell_id` retains the source spell +reference even when the Stat Kind is unknown. + +Enchantment IDs 7745 and 7748 are authored compatibility entries, with +3 Strength and +3 Stamina. +They preserve existing stored item meanings. They are not rows from the build 5875 client +catalogue. The importer refuses a client catalogue that collides with either ID. diff --git a/crates/lyracore-shared/src/item_property.rs b/crates/lyracore-shared/src/item_property.rs new file mode 100644 index 000000000..3ecb59687 --- /dev/null +++ b/crates/lyracore-shared/src/item_property.rs @@ -0,0 +1,33 @@ +//! Append-only Stat Kind codes used by item catalogue authors and the importer. + +pub const UNKNOWN: u8 = 0; +pub const STRENGTH: u8 = 1; +pub const AGILITY: u8 = 2; +pub const STAMINA: u8 = 3; +pub const INTELLECT: u8 = 4; +pub const SPIRIT: u8 = 5; +pub const HEALTH: u8 = 6; +pub const MANA: u8 = 7; +pub const HOLY_RESISTANCE: u8 = 8; +pub const FIRE_RESISTANCE: u8 = 9; +pub const NATURE_RESISTANCE: u8 = 10; +pub const FROST_RESISTANCE: u8 = 11; +pub const SHADOW_RESISTANCE: u8 = 12; +pub const ARCANE_RESISTANCE: u8 = 13; +pub const ARMOR: u8 = 14; +pub const WEAPON_DAMAGE: u8 = 15; +pub const SPELL_POWER: u8 = 16; +pub const HEALING_POWER: u8 = 17; +pub const MANA_PER_FIVE: u8 = 18; +pub const HEALTH_PER_FIVE: u8 = 19; +pub const CRIT: u8 = 20; +pub const HIT: u8 = 21; +pub const DEFENSE: u8 = 22; +pub const DODGE: u8 = 23; +pub const PARRY: u8 = 24; +pub const BLOCK: u8 = 25; + +/// Authored compatibility entries, separate from client enchantment IDs. +/// Existing stored items and synthetic enchanting spells use these meanings. +pub const COMPATIBILITY_ENCHANTMENTS: [(u32, u8, i32); 2] = + [(7745, STRENGTH, 3), (7748, STAMINA, 3)]; diff --git a/crates/lyracore-shared/src/lib.rs b/crates/lyracore-shared/src/lib.rs index bd52fb87d..10c7bc3b3 100644 --- a/crates/lyracore-shared/src/lib.rs +++ b/crates/lyracore-shared/src/lib.rs @@ -45,3 +45,5 @@ pub const ERR_ATTACK_TARGET_DEAD: &str = "target is dead"; /// maps it to `SMSG_ATTACKSWING_CANT_ATTACK` so the client leaves stance cleanly. Shared so producer /// (module) and matcher (gateway) can't drift. pub const ERR_ATTACK_FRIENDLY: &str = "target is friendly"; + +pub mod item_property; diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index d0f50657b..371693d7c 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -89,21 +89,34 @@ pub mod vote_kind { } /// Encode a `ROLL_START` payload: `corpse_guid,slot,item_entry,countdown_ms`. -pub fn encode_start(corpse_guid: u64, slot: u8, item_entry: u32, countdown_ms: u32) -> String { - format!("{corpse_guid},{slot},{item_entry},{countdown_ms}") +pub fn encode_start( + corpse_guid: u64, + slot: u8, + item_entry: u32, + countdown_ms: u32, + random_property_id: u32, +) -> String { + format!("{corpse_guid},{slot},{item_entry},{countdown_ms},{random_property_id}") } /// Decode a `ROLL_START` payload back to `(corpse_guid, slot, item_entry, countdown_ms)`. -pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32)> { +pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; let slot: u8 = p.next()?.parse().ok()?; let item_entry: u32 = p.next()?.parse().ok()?; let countdown_ms: u32 = p.next()?.parse().ok()?; + let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0); if p.next().is_some() { return None; // trailing garbage — fail closed } - Some((corpse_guid, slot, item_entry, countdown_ms)) + Some(( + corpse_guid, + slot, + item_entry, + countdown_ms, + random_property_id, + )) } /// Encode a `ROLL_VOTE` payload: `corpse_guid,slot,item_entry,roll_number,vote_kind,auto_pass`. @@ -116,15 +129,16 @@ pub fn encode_vote( roll_number: u8, vote: u8, auto_pass: bool, + random_property_id: u32, ) -> String { format!( - "{corpse_guid},{slot},{item_entry},{roll_number},{vote},{}", + "{corpse_guid},{slot},{item_entry},{roll_number},{vote},{},{random_property_id}", auto_pass as u8 ) } /// Decode a `ROLL_VOTE` payload back to `(corpse_guid, slot, item_entry, roll_number, vote_kind, auto_pass)`. -pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool)> { +pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; let slot: u8 = p.next()?.parse().ok()?; @@ -132,10 +146,19 @@ pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool)> { let roll_number: u8 = p.next()?.parse().ok()?; let vote: u8 = p.next()?.parse().ok()?; let auto_pass = p.next()? == "1"; + let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0); if p.next().is_some() { return None; } - Some((corpse_guid, slot, item_entry, roll_number, vote, auto_pass)) + Some(( + corpse_guid, + slot, + item_entry, + roll_number, + vote, + auto_pass, + random_property_id, + )) } /// Encode a `ROLL_WON` payload: `corpse_guid,slot,item_entry,winning_roll,winning_vote`. The @@ -148,22 +171,31 @@ pub fn encode_won( item_entry: u32, winning_roll: u8, winning_vote: u8, + random_property_id: u32, ) -> String { - format!("{corpse_guid},{slot},{item_entry},{winning_roll},{winning_vote}") + format!("{corpse_guid},{slot},{item_entry},{winning_roll},{winning_vote},{random_property_id}") } /// Decode a `ROLL_WON` payload back to `(corpse_guid, slot, item_entry, winning_roll, winning_vote)`. -pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8)> { +pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; let slot: u8 = p.next()?.parse().ok()?; let item_entry: u32 = p.next()?.parse().ok()?; let winning_roll: u8 = p.next()?.parse().ok()?; let winning_vote: u8 = p.next()?.parse().ok()?; + let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0); if p.next().is_some() { return None; } - Some((corpse_guid, slot, item_entry, winning_roll, winning_vote)) + Some(( + corpse_guid, + slot, + item_entry, + winning_roll, + winning_vote, + random_property_id, + )) } /// Encode a `MASTER_LIST` payload: `corpse_guid|guid,guid,...` (the eligible-recipient set). @@ -198,31 +230,48 @@ pub fn decode_money_share(payload: &str) -> Option { mod tests { use super::*; + #[test] + fn saved_properties_cross_events_and_old_events_stay_plain() { + assert_eq!( + decode_start(&encode_start(1, 2, 3, 4000, 117)), + Some((1, 2, 3, 4000, 117)) + ); + assert_eq!(decode_start("1,2,3,4000"), Some((1, 2, 3, 4000, 0))); + assert_eq!( + decode_vote(&encode_vote(1, 2, 3, 99, 1, false, 117)), + Some((1, 2, 3, 99, 1, false, 117)) + ); + assert_eq!( + decode_won(&encode_won(1, 2, 3, 99, 1, 117)), + Some((1, 2, 3, 99, 1, 117)) + ); + } + #[test] fn start_round_trips() { - let wire = encode_start(555, 2, 1234, 60_000); - assert_eq!(decode_start(&wire), Some((555, 2, 1234, 60_000))); + let wire = encode_start(555, 2, 1234, 60_000, 0); + assert_eq!(decode_start(&wire), Some((555, 2, 1234, 60_000, 0))); } #[test] fn start_fails_closed_on_garbage() { assert!(decode_start("").is_none()); assert!(decode_start("1,2,3").is_none()); // missing countdown - assert!(decode_start("1,2,3,4,5").is_none()); // trailing garbage + assert!(decode_start("1,2,3,4,5,6").is_none()); // trailing garbage assert!(decode_start("x,2,3,4").is_none()); } #[test] fn vote_round_trips_including_auto_pass() { - let wire = encode_vote(555, 2, 1234, 87, vote_kind::NEED, false); + let wire = encode_vote(555, 2, 1234, 87, vote_kind::NEED, false, 0); assert_eq!( decode_vote(&wire), - Some((555, 2, 1234, 87, vote_kind::NEED, false)) + Some((555, 2, 1234, 87, vote_kind::NEED, false, 0)) ); - let auto = encode_vote(555, 2, 1234, 0, vote_kind::PASS, true); + let auto = encode_vote(555, 2, 1234, 0, vote_kind::PASS, true, 0); assert_eq!( decode_vote(&auto), - Some((555, 2, 1234, 0, vote_kind::PASS, true)) + Some((555, 2, 1234, 0, vote_kind::PASS, true, 0)) ); } @@ -237,10 +286,10 @@ mod tests { #[test] fn won_round_trips() { - let wire = encode_won(555, 2, 1234, 96, vote_kind::GREED); + let wire = encode_won(555, 2, 1234, 96, vote_kind::GREED, 0); assert_eq!( decode_won(&wire), - Some((555, 2, 1234, 96, vote_kind::GREED)) + Some((555, 2, 1234, 96, vote_kind::GREED, 0)) ); } diff --git a/crates/lyracore-shared/src/trade.rs b/crates/lyracore-shared/src/trade.rs index 710712084..af1a44d0b 100644 --- a/crates/lyracore-shared/src/trade.rs +++ b/crates/lyracore-shared/src/trade.rs @@ -73,6 +73,7 @@ pub struct OfferSlot { pub enchantment: u32, pub durability: u32, pub max_durability: u32, + pub random_property_id: u32, } /// Encode one side's whole offer for an `OFFER_*` event payload: @@ -83,14 +84,15 @@ pub fn encode_offer(gold: u32, slots: &[OfferSlot]) -> String { .iter() .map(|s| { format!( - "{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{}", s.trade_slot, s.entry, s.display_id, s.stack_count, s.enchantment, s.durability, - s.max_durability + s.max_durability, + s.random_property_id ) }) .collect(); @@ -105,7 +107,7 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec)> { let mut slots = Vec::new(); for part in body.split(';').filter(|p| !p.is_empty()) { let f: Vec<&str> = part.split(',').collect(); - if f.len() != 7 { + if f.len() != 7 && f.len() != 8 { return None; } slots.push(OfferSlot { @@ -116,6 +118,7 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec)> { enchantment: f[4].parse().ok()?, durability: f[5].parse().ok()?, max_durability: f[6].parse().ok()?, + random_property_id: f.get(7).map(|v| v.parse()).transpose().ok()?.unwrap_or(0), }); } Some((gold, slots)) @@ -139,6 +142,7 @@ mod tests { enchantment: 0, durability: 0, max_durability: 0, + random_property_id: 117, }, OfferSlot { trade_slot: 6, @@ -148,6 +152,7 @@ mod tests { enchantment: 2564, durability: 34, max_durability: 40, + random_property_id: 0, }, ]; let payload = encode_offer(1_2345, &slots); diff --git a/gateway/src/codec/auction.rs b/gateway/src/codec/auction.rs index de0d6ac0e..7b496e5e5 100644 --- a/gateway/src/codec/auction.rs +++ b/gateway/src/codec/auction.rs @@ -20,6 +20,7 @@ pub struct AuctionView { pub highest_bidder_guid: u64, pub highest_bid: u32, pub expires_at_micros: i64, + pub random_property_id: u32, } pub fn build_auction_list_item(view: &AuctionView, now_micros: i64) -> AuctionListItem { @@ -30,7 +31,7 @@ pub fn build_auction_list_item(view: &AuctionView, now_micros: i64) -> AuctionLi id: view.id, item: view.item_entry, item_enchantment: view.item_enchant_id, - item_random_property_id: 0, + item_random_property_id: view.random_property_id, item_suffix_factor: 0, item_count: view.item_stack_count, item_charges: 0, @@ -103,6 +104,7 @@ mod tests { highest_bidder_guid: 20, highest_bid: 201, expires_at_micros: 3_500_000, + random_property_id: 509_0101, } } @@ -112,7 +114,7 @@ mod tests { assert_eq!(mapped.id, 9); assert_eq!(mapped.item, 25); assert_eq!(mapped.item_enchantment, 7); - assert_eq!(mapped.item_random_property_id, 0); + assert_eq!(mapped.item_random_property_id, 509_0101); assert_eq!(mapped.item_suffix_factor, 0); assert_eq!(mapped.item_count, 2); assert_eq!(mapped.item_charges, 0); diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index bf2988e01..6b1c9b810 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -290,7 +290,7 @@ pub fn skill_slot_layout(class_b: u8, learned: &[(u32, u16, u16)]) -> Vec<(Skill pub fn build_create_object( entity: &EntityView, kind: CreateKind, - inventory: &[(u8, u64, u32)], + inventory: &[(u8, u64, u32, u32)], learned_skills: &[(u32, u16, u16)], ) -> Result { // race/class/gender/power are packed in unit_bytes_0 for both players and creatures (a creature @@ -453,7 +453,7 @@ pub fn build_create_object( // For an EQUIPMENT slot (0..=18) also set PLAYER_VISIBLE_ITEM[slot] to the item ENTRY — that // descriptor is what makes the weapon/armor appear ON the 3D character model (slice-2). Both // are full-CREATE-mask fields (OBJECT_FIELD_TYPE belongs here), so no dirty_reset concern. - for &(slot, item_guid, entry) in inventory { + for &(slot, item_guid, entry, random_property_id) in inventory { if let Ok(s) = ItemSlot::try_from(slot) { builder = builder.set_player_field_inv(s, Guid::new(item_guid)); } @@ -461,6 +461,7 @@ pub fn build_create_object( builder = builder.set_player_visible_item( VisibleItem { item: entry, + random_property_id, ..Default::default() }, vi_index, diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index 485d3f6fd..9ba734541 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -79,6 +79,7 @@ pub struct ItemTemplateView { pub bag_family: u32, pub allowed_class: u32, pub allowed_race: u32, + pub random_property: u32, } /// An owned-item instance as the gateway reads it from `game_item_instance` (items slice-1), @@ -96,6 +97,7 @@ pub struct ItemInstanceView { /// bags are sent as `ObjectType::Container` with `CONTAINER_FIELD_NUM_SLOTS` set so the client /// opens the bag window. Non-bags use `ObjectType::Item` as before (baseline-safe). pub container_slots: u8, + pub random_property_id: u32, } /// Build `SMSG_ITEM_QUERY_SINGLE_RESPONSE` so the client caches the item's name/tooltip/icon (the @@ -241,6 +243,7 @@ pub fn build_item_query_response( // mangos column defaults to 100.0 for ALL items (only ranged weapons consult it); we don't // import per-item overrides, so send the universal vanilla default. [hunter Auto Shot fix] ranged_range_modification: 100.0, + random_property: t.random_property, ..Default::default() }), } @@ -408,6 +411,7 @@ pub fn build_item_create_object(inst: &ItemInstanceView) -> SMSG_UPDATE_OBJECT { .set_object_guid(guid3) .set_object_entry(inst.entry as i32) .set_object_scale_x(1.0) + .set_item_random_properties_id(inst.random_property_id as i32) .set_item_owner(owner) .set_item_contained(owner) .set_item_stack_count(inst.stack_count.max(1) as i32) @@ -430,6 +434,7 @@ pub fn build_item_create_object(inst: &ItemInstanceView) -> SMSG_UPDATE_OBJECT { .set_object_guid(guid3) .set_object_entry(inst.entry as i32) .set_object_scale_x(1.0) + .set_item_random_properties_id(inst.random_property_id as i32) .set_item_owner(owner) .set_item_contained(owner) .set_item_stack_count(inst.stack_count.max(1) as i32) @@ -466,6 +471,7 @@ pub fn build_item_push_result( entry: u32, count: u32, stack_add: bool, + random_property_id: u32, ) -> SMSG_ITEM_PUSH_RESULT { SMSG_ITEM_PUSH_RESULT { guid: Guid::new(player_guid), @@ -476,7 +482,7 @@ pub fn build_item_push_result( item_slot: if stack_add { 0xFFFF_FFFF } else { item_slot }, item: entry, item_suffix_factor: 0, - item_random_property_id: 0, + item_random_property_id: random_property_id, item_count: count, } } diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 8d000d748..9e08bf189 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -13,7 +13,7 @@ const SMSG_LOOT_RESPONSE_OPCODE: u16 = 0x0160; /// One lootable item for the loot window: `(slot, item_id, count, display_id)`. `display_id` is the /// item's `ItemDisplayInfo` id (the gateway joins it from `game_item_template`); the client also /// queries the item (`CMSG_ITEM_QUERY_SINGLE`) for the name/tooltip (slice-1 path). -pub type LootItemView = (u8, u32, u32, u32); +pub type LootItemView = (u8, u32, u32, u32, u32); /// Build a RAW `SMSG_LOOT_RESPONSE` (slice 3 money + slice 4 items). RAW because gtker's typed /// `LootItem` is INCOMPLETE for vanilla 1.12 — it encodes only `index:u8 + item:u32 + ty:u8` (6 @@ -32,13 +32,13 @@ pub fn build_loot_response_raw(guid: u64, money: u32, items: &[LootItemView]) -> body.push(1u8); // loot_method = Corpse (SMSG_LOOT_RESPONSE_LootMethod::Corpse as_int) body.extend_from_slice(&money.to_le_bytes()); // gold: u32 body.push(items.len() as u8); // amount_of_items: u8 - for &(slot, item_id, count, display_id) in items { + for &(slot, item_id, count, display_id, random_property_id) in items { body.push(slot); // loot slot index: u8 body.extend_from_slice(&item_id.to_le_bytes()); // item id (entry): u32 body.extend_from_slice(&count.to_le_bytes()); // stack count: u32 body.extend_from_slice(&display_id.to_le_bytes()); // ItemDisplayInfo id: u32 body.extend_from_slice(&0u32.to_le_bytes()); // random_suffix: u32 (none) - body.extend_from_slice(&0u32.to_le_bytes()); // random_property_id: u32 (none) + body.extend_from_slice(&random_property_id.to_le_bytes()); body.push(0u8); // slot_type = TypeAllowLoot (as_int 0) } (SMSG_LOOT_RESPONSE_OPCODE, body) @@ -93,13 +93,14 @@ pub fn build_loot_start_roll( loot_slot: u8, item_entry: u32, countdown_ms: u32, + random_property_id: u32, ) -> SMSG_LOOT_START_ROLL { SMSG_LOOT_START_ROLL { creature: Guid::new(corpse_guid), loot_slot: loot_slot as u32, item: item_entry, item_random_suffix: 0, - item_random_property_id: 0, + item_random_property_id: random_property_id, countdown_time: std::time::Duration::from_millis(countdown_ms as u64), } } @@ -131,6 +132,7 @@ pub fn build_loot_roll( rolled: u8, vote: u8, _auto_pass: bool, + random_property_id: u32, ) -> SMSG_LOOT_ROLL { SMSG_LOOT_ROLL { creature: Guid::new(corpse_guid), @@ -138,7 +140,7 @@ pub fn build_loot_roll( player: Guid::new(voter_guid), item: item_entry, item_random_suffix: 0, - item_random_property_id: 0, + item_random_property_id: random_property_id, roll_number: wire_roll_number(vote, rolled), vote: wire_vote(vote), } @@ -152,13 +154,14 @@ pub fn build_loot_roll_won( winning_player: u64, winning_roll: u8, winning_vote: u8, + random_property_id: u32, ) -> SMSG_LOOT_ROLL_WON { SMSG_LOOT_ROLL_WON { looted_target: Guid::new(corpse_guid), loot_slot: loot_slot as u32, item: item_entry, item_random_suffix: 0, - item_random_property_id: 0, + item_random_property_id: random_property_id, winning_player: Guid::new(winning_player), winning_roll, // The tier that actually won — a greed-only contest reports Greed, not Need diff --git a/gateway/src/codec/mail.rs b/gateway/src/codec/mail.rs index 348ce9e8a..6e0e70004 100644 --- a/gateway/src/codec/mail.rs +++ b/gateway/src/codec/mail.rs @@ -23,6 +23,7 @@ pub struct MailView { pub cod: u32, pub was_read: bool, pub created_at_secs: i64, + pub random_property_id: u32, } pub fn build_mail_list(mails: &[MailView], now_secs: i64) -> SMSG_MAIL_LIST_RESULT { SMSG_MAIL_LIST_RESULT { @@ -39,7 +40,7 @@ pub fn build_mail_list(mails: &[MailView], now_secs: i64) -> SMSG_MAIL_LIST_RESU stationery: 41, item: m.item_entry, item_enchant_id: m.item_enchant_id, - item_random_property_id: 0, + item_random_property_id: m.random_property_id, item_suffix_factor: 0, item_stack_size: m.item_stack_count.min(u8::MAX as u32) as u8, item_spell_charges: 0, diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 9bccabda0..b55bec3b0 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -420,7 +420,7 @@ fn loot_response_item_layout_is_full_vanilla() { // One item must serialize the FULL vanilla 22-byte layout (slot, id, count, display, 0,0, ty), // NOT gtker's short 6-byte form. Spot-check the offsets + total length. let guid = 0xF130_0000_0000_0001; - let (_op, body) = build_loot_response_raw(guid, 5, &[(0, 25, 2, 1542)]); + let (_op, body) = build_loot_response_raw(guid, 5, &[(0, 25, 2, 1542, 117)]); // envelope = 8 (guid) + 1 (method) + 4 (gold) + 1 (count) = 14; one item = 22 → 36 total. assert_eq!( body.len(), @@ -445,6 +445,7 @@ fn loot_response_item_layout_is_full_vanilla() { 1542, "display id" ); + assert_eq!(u32::from_le_bytes(it[17..21].try_into().unwrap()), 117); assert_eq!(it[21], 0, "slot_type = TypeAllowLoot"); } @@ -452,10 +453,11 @@ fn loot_response_item_layout_is_full_vanilla() { #[test] fn loot_start_roll_carries_the_countdown_and_item() { - let m = build_loot_start_roll(0xF130_0000_0000_0007, 3, 1234, 60_000); + let m = build_loot_start_roll(0xF130_0000_0000_0007, 3, 1234, 60_000, 117); assert_eq!(m.creature.guid(), 0xF130_0000_0000_0007); assert_eq!(m.loot_slot, 3); assert_eq!(m.item, 1234); + assert_eq!(m.item_random_property_id, 117); assert_eq!(m.countdown_time, std::time::Duration::from_millis(60_000)); // Round-trips through the real vanilla wire encode without error (opcode 0x02A1). let msg = ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new(m)); @@ -489,6 +491,7 @@ fn loot_roll_carries_the_voter_item_and_vote() { 87, vote_kind::NEED, false, + 0, ); assert_eq!(m.creature.guid(), 0xF130_0000_0000_0007); assert_eq!(m.loot_slot, 3); @@ -497,14 +500,23 @@ fn loot_roll_carries_the_voter_item_and_vote() { assert_eq!(m.roll_number, 87); assert_eq!(m.vote, RollVote::Need); // A PASS vote's roll_number folds to the >127 sentinel regardless of the stored `rolled` (0). - let pass = build_loot_roll(0xF130_0000_0000_0007, 3, 55, 1234, 0, vote_kind::PASS, true); + let pass = build_loot_roll( + 0xF130_0000_0000_0007, + 3, + 55, + 1234, + 0, + vote_kind::PASS, + true, + 0, + ); assert_eq!(pass.roll_number, 128); assert_eq!(pass.vote, RollVote::Pass); } #[test] fn loot_roll_won_carries_the_winner_roll_and_winning_tier() { - let m = build_loot_roll_won(0xF130_0000_0000_0007, 3, 1234, 55, 96, vote_kind::NEED); + let m = build_loot_roll_won(0xF130_0000_0000_0007, 3, 1234, 55, 96, vote_kind::NEED, 0); assert_eq!(m.looted_target.guid(), 0xF130_0000_0000_0007); assert_eq!(m.loot_slot, 3); assert_eq!(m.item, 1234); @@ -512,7 +524,7 @@ fn loot_roll_won_carries_the_winner_roll_and_winning_tier() { assert_eq!(m.winning_roll, 96); assert_eq!(m.vote, RollVote::Need); // A greed-only contest reports Greed — the tier is threaded, not hardcoded. - let g = build_loot_roll_won(0xF130_0000_0000_0007, 3, 1234, 55, 42, vote_kind::GREED); + let g = build_loot_roll_won(0xF130_0000_0000_0007, 3, 1234, 55, 42, vote_kind::GREED, 0); assert_eq!(g.vote, RollVote::Greed); } @@ -1664,6 +1676,7 @@ fn ring_of_fire_resistance() -> ItemTemplateView { bag_family: 6, // Herbs allowed_class: 0x8000_0002, allowed_race: 0x8000_0004, + random_property: 509_0100, ..Default::default() } } @@ -1681,6 +1694,7 @@ fn item_query_response_carries_the_work_item_213_columns() { match ServerOpcodeMessage::read_unencrypted(&mut buf.as_slice()).unwrap() { ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(m) => { let found = m.found.expect("Ring of Fire Resistance must be found"); + assert_eq!(found.random_property, 509_0100); assert_eq!(found.holy_resistance, 0); assert_eq!(found.fire_resistance, 12, "fire_res must reach the wire"); assert_eq!(found.nature_resistance, 0); @@ -1787,6 +1801,7 @@ fn item_create_object_is_item_typed_and_serializes() { durability: 20, max_durability: 20, container_slots: 0, + random_property_id: 509_0101, }; let msg = build_item_create_object(&inst); let mut buf = Vec::new(); @@ -1803,6 +1818,7 @@ fn item_create_object_is_item_typed_and_serializes() { assert_eq!(it.item_owner(), Some(Guid::new(1))); assert_eq!(it.item_contained(), Some(Guid::new(1))); assert_eq!(it.item_maxdurability(), Some(20)); + assert_eq!(it.item_random_properties_id(), Some(509_0101)); } other => panic!("expected an Item CreateObject2, got {other:?}"), } @@ -3573,6 +3589,7 @@ fn item_create_object_bag_slots_build_a_container_with_num_slots() { durability: 0, max_durability: 0, container_slots: 8, // an 8-slot bag + random_property_id: 509_0101, }; let msg = build_item_create_object(&inst); let mut buf = Vec::new(); @@ -3590,6 +3607,7 @@ fn item_create_object_bag_slots_build_a_container_with_num_slots() { "a bag must build a CONTAINER object" ); assert_eq!(c.object_entry(), Some(5000)); + assert_eq!(c.item_random_properties_id(), Some(509_0101)); assert_eq!(c.item_owner(), Some(Guid::new(1))); assert_eq!( c.container_num_slots(), @@ -3678,15 +3696,17 @@ fn creature_query_response_maps_names_and_flags_and_serializes() { #[test] fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { // Equip: the VALUES partial must carry PLAYER_VISIBLE_ITEM[15] = the item entry. - let msg = build_visible_item_values(1, 15, 25).expect("slot 15 (mainhand) is equipment"); + let msg = build_visible_item_values(1, 15, 25, 117).expect("slot 15 (mainhand) is equipment"); match &msg.objects[0] { Object::Values { mask1: UpdateMask::Player(p), .. } => { + assert!(p.object_type().is_none()); let vi = p .player_visible_item(VisibleItemIndex::Index15) .expect("visible item set"); + assert_eq!(vi.random_property_id, 117); assert_eq!( vi.item, 25, "the mainhand VISIBLE_ITEM must carry the equipped entry" @@ -3695,7 +3715,7 @@ fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { other => panic!("expected a Player Values update, got {other:?}"), } // Unequip: entry 0 is a real (zeroed) write, not a skipped update. - let cleared = build_visible_item_values(1, 15, 0).unwrap(); + let cleared = build_visible_item_values(1, 15, 0, 0).unwrap(); match &cleared.objects[0] { Object::Values { mask1: UpdateMask::Player(p), @@ -3709,9 +3729,9 @@ fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { other => panic!("expected a Player Values update, got {other:?}"), } // Non-equipment slots (backpack 23, bags 19, bag contents 120+) are None — no model residue. - assert!(build_visible_item_values(1, 23, 25).is_none()); - assert!(build_visible_item_values(1, 19, 25).is_none()); - assert!(build_visible_item_values(1, 120, 25).is_none()); + assert!(build_visible_item_values(1, 23, 25, 0).is_none()); + assert!(build_visible_item_values(1, 19, 25, 0).is_none()); + assert!(build_visible_item_values(1, 120, 25, 0).is_none()); } // =========================================================================================== @@ -4067,3 +4087,18 @@ fn login_sequence_emits_a_current_realm_clock_not_the_old_hardcode() { "emitted datetime {emitted} must fall within [{lower}, {upper}], a couple of minutes around now" ); } + +#[test] +fn item_push_and_mail_keep_the_instance_property() { + let push = build_item_push_result(1, 0, 23, 25, 1, false, 117); + assert_eq!(push.item_random_property_id, 117); + let mail = MailView { + item_entry: 25, + random_property_id: 117, + ..Default::default() + }; + assert_eq!( + build_mail_list(&[mail], 0).mails[0].item_random_property_id, + 117 + ); +} diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index d1cee5d93..edcfb9537 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -511,12 +511,14 @@ pub fn build_visible_item_values( player_guid: u64, slot: u8, entry: u32, + random_property_id: u32, ) -> Option { let vi_index = super::entity::visible_item_index(slot)?; Some(player_values(player_guid, |player| { player.set_player_visible_item( VisibleItem { item: entry, + random_property_id, ..Default::default() }, vi_index, @@ -658,7 +660,7 @@ mod lint_tests { ), ( "visible_item", - build_visible_item_values(g, 15, 25).unwrap(), + build_visible_item_values(g, 15, 25, 0).unwrap(), ), ("item", build_item_values(0x4000_0000_0000_0001, 5, 70)), ]; diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index d90129b2b..cfffb865c 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -10,10 +10,8 @@ //! term is 0, which the on_aura relay corrects the instant a login-present aura inserts), and //! - the aura / gear relays on the PER-PLAYER cache (base + auras + gear; all three tables subscribed). //! -//! It mirrors `module::combat::effective_armor` term-for-term: `e.armor` base, `spell::resistance_bonus` -//! (A_MOD_RESISTANCE armor auras, amount×stacks), `items::equipped_stat_bonus(Armor)` (worn `stat_armor`, -//! broken-skip). No armor ENCHANT exists in the module (its `ENCHANTS` table is STR/STA only), so there is -//! no enchant overlay term and the gateway matches the module exactly for armor. +//! It mirrors the Module's armor fold, including catalogue effects for applied enchantments +//! and Random Properties. The other character-sheet values below come directly from the Module. //! //! [`sheet_stats`] (the STR/AGI/STA/INT/SPI/AP/damage-range/crit half of the paperdoll, #517 + #532) is //! NOT a gateway-side fold like the Armor half above — it is a plain READ of @@ -150,8 +148,25 @@ pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { .entry() .find(&i.entry) .map(|t| { - gear_armor_contribution(i.slot, t.stat_armor, t.max_durability, i.durability) - as i64 + let mut armor = t.stat_armor.saturating_add(enchant_armor(db, i.enchant_id)); + if i.random_property_id != 0 { + if let Some(property) = db + .game_item_random_property() + .property_id() + .find(&i.random_property_id) + { + let bonus = [ + property.enchant_id_1, + property.enchant_id_2, + property.enchant_id_3, + ] + .into_iter() + .map(|id| enchant_armor(db, id)) + .fold(0i32, i32::saturating_add); + armor = armor.saturating_add(bonus); + } + } + gear_armor_contribution(i.slot, armor, t.max_durability, i.durability) as i64 }) .unwrap_or(0) // a missing template join never poisons the sum (matches the module) }) @@ -159,6 +174,18 @@ pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { (base + aura_sum + gear_sum).max(0) as u32 } +fn enchant_armor(db: &RemoteTables, enchant_id: u32) -> i32 { + if enchant_id == 0 { + return 0; + } + db.game_item_enchantment() + .iter() + .filter(|effect| { + effect.enchant_id == enchant_id && effect.kind == lyracore_shared::item_property::ARMOR + }) + .fold(0i32, |sum, effect| sum.saturating_add(effect.amount)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index e6eef3765..3f40315be 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -905,6 +905,8 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { // owned-item instance. The coordinator authenticates as the module owner, which bypasses // RLS, so it reads all players' item rows (like game_character) to drive each login spawn. "SELECT * FROM game_item_template", + "SELECT * FROM game_item_random_property", + "SELECT * FROM game_item_enchantment", // Static spell headers — spell_cast_time() reads cast_time_ms via coord() so the // CMSG_CAST_SPELL handler can clear INSTANT casts synchronously. "SELECT * FROM game_spell", diff --git a/gateway/src/stdb/reads/auction.rs b/gateway/src/stdb/reads/auction.rs index 87a3f63c7..a86306f0c 100644 --- a/gateway/src/stdb/reads/auction.rs +++ b/gateway/src/stdb/reads/auction.rs @@ -104,6 +104,7 @@ fn auction_view(row: Auction) -> crate::codec::AuctionView { highest_bidder_guid: row.highest_bidder_guid, highest_bid: row.highest_bid, expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + random_property_id: row.random_property_id, } } @@ -332,6 +333,7 @@ mod tests { item_durability: 10, item_enchant_id: 0, item_soulbound: false, + random_property_id: 0, start_bid: 1, buyout: 0, highest_bidder_guid: 0, diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index d86bc50f7..4e424005b 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -48,6 +48,7 @@ impl Coordinator { durability: i.durability, max_durability: tmpl.max_durability, container_slots: tmpl.container_slots, + random_property_id: i.random_property_id, }) }) .collect(); @@ -134,7 +135,13 @@ impl Coordinator { .find(&l.item_entry) .map(|t| t.display_id) .unwrap_or(0); - (l.slot, l.item_entry, l.count, display_id) + ( + l.slot, + l.item_entry, + l.count, + display_id, + l.random_property_id, + ) }) .collect(); items.sort_by_key(|(slot, ..)| *slot); // stable loot-slot order (SQL has no ORDER BY in 2.5) @@ -159,7 +166,7 @@ impl Coordinator { } /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` ≤12. - pub fn buyback_ring(&self, player_guid: u64) -> Vec<(u32, u32, u32)> { + pub fn buyback_ring(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { let guard = self.0.coord(); let db = &guard.conn.db; let mut rows: Vec<_> = db @@ -169,7 +176,7 @@ impl Coordinator { .collect(); rows.sort_by(|a, b| b.id.cmp(&a.id)); rows.into_iter() - .map(|b| (b.item_entry, b.stack_count, b.price)) + .map(|b| (b.item_entry, b.stack_count, b.price, b.random_property_id)) .collect() } } diff --git a/gateway/src/stdb/reads/mail.rs b/gateway/src/stdb/reads/mail.rs index 87d83d443..9e728889a 100644 --- a/gateway/src/stdb/reads/mail.rs +++ b/gateway/src/stdb/reads/mail.rs @@ -40,6 +40,7 @@ impl Coordinator { .unwrap_or(0), item_enchant_id: m.item_enchant_id, item_soulbound: m.item_soulbound, + random_property_id: m.random_property_id, money: m.money, cod: m.cod, was_read: m.was_read, @@ -83,6 +84,7 @@ impl Coordinator { durability: e.item_durability, enchant_id: e.item_enchant_id, soulbound: e.item_soulbound, + random_property_id: e.random_property_id, }, cod: e.cod, }) diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index c8fd510b8..566d1fd6f 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -86,6 +86,7 @@ impl Coordinator { item_entry: r.item_entry, deadline_micros: r.deadline_micros, recipients, + random_property_id: r.random_property_id, } }) .collect()) diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 1067939a0..393b302dc 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -375,6 +375,7 @@ impl Coordinator { hold.item_durability, hold.item_enchant_id, hold.item_soulbound, + hold.random_property_id, hold.house, hold.deposit_rate, hold.consignment_rate, @@ -417,6 +418,7 @@ impl Coordinator { hold.item_durability, hold.item_enchant_id, hold.item_soulbound, + hold.random_property_id, hold.house, hold.deposit_rate, hold.consignment_rate, @@ -2794,6 +2796,7 @@ impl Coordinator { item.durability, item.enchant_id, item.soulbound, + item.random_property_id, cod, cod_source_mail_id ) @@ -2868,7 +2871,8 @@ impl Coordinator { item.stack_count, item.durability, item.enchant_id, - item.soulbound + item.soulbound, + item.random_property_id ) ) } @@ -2930,6 +2934,7 @@ impl Coordinator { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -2942,7 +2947,8 @@ impl Coordinator { actor_guid, vote, deadline_micros, - recipients + recipients, + random_property_id ) ) } @@ -2966,7 +2972,8 @@ impl Coordinator { actor_guid, vote, 0, - Vec::new() + Vec::new(), + 0 ) )) } diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 73b9039f3..7cf93c108 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -270,14 +270,14 @@ fn build_peer_create( viewer_guid: u64, row: &WorldEntity, ) -> Option> { - let inv: Vec<(u8, u64, u32)> = + let inv: Vec<(u8, u64, u32, u32)> = if row.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 { coord .player_items(row.guid) .unwrap_or_default() .into_iter() .filter(|i| i.slot <= 18) - .map(|i| (i.slot, i.guid, i.entry)) + .map(|i| (i.slot, i.guid, i.entry, i.random_property_id)) .collect() } else { Vec::new() @@ -298,7 +298,7 @@ fn build_peer_create( /// could see it, so another aura callback cannot be relied on to supply them. fn peer_create_outbound( view: &codec::EntityView, - inventory: &[(u8, u64, u32)], + inventory: &[(u8, u64, u32, u32)], auras: &[Aura], ) -> Result> { let create = codec::build_create_object(view, CreateKind::Peer, inventory, &[])?; @@ -2026,6 +2026,7 @@ fn trade_offer_extended( display_id: v.display_id, stack_count: v.stack_count, enchantment: v.enchantment, + item_random_properties_id: v.random_property_id, max_durability: v.max_durability, durability: v.durability, ..TradeSlot::default() @@ -2139,11 +2140,15 @@ pub(crate) fn group_event_outbound( } }, roll_kind::ROLL_START => match lyracore_shared::loot_roll::decode_start(&row.payload) { - Some((corpse_guid, slot, item_entry, countdown_ms)) => { - Some(ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new( - codec::build_loot_start_roll(corpse_guid, slot, item_entry, countdown_ms), - ))) - } + Some((corpse_guid, slot, item_entry, countdown_ms, random_property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new(codec::build_loot_start_roll( + corpse_guid, + slot, + item_entry, + countdown_ms, + random_property_id, + ))), + ), None => { log::warn!( "loot ROLL_START relay: unparseable payload {:?} (event {})", @@ -2154,8 +2159,16 @@ pub(crate) fn group_event_outbound( } }, roll_kind::ROLL_VOTE => match lyracore_shared::loot_roll::decode_vote(&row.payload) { - Some((corpse_guid, slot, item_entry, roll_number, vote, auto_pass)) => Some( - ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new(codec::build_loot_roll( + Some(( + corpse_guid, + slot, + item_entry, + roll_number, + vote, + auto_pass, + random_property_id, + )) => Some(ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new( + codec::build_loot_roll( corpse_guid, slot, row.other_guid, @@ -2163,8 +2176,9 @@ pub(crate) fn group_event_outbound( roll_number, vote, auto_pass, - ))), - ), + random_property_id, + ), + ))), None => { log::warn!( "loot ROLL_VOTE relay: unparseable payload {:?} (event {})", @@ -2175,16 +2189,24 @@ pub(crate) fn group_event_outbound( } }, roll_kind::ROLL_WON => match lyracore_shared::loot_roll::decode_won(&row.payload) { - Some((corpse_guid, slot, item_entry, winning_roll, winning_vote)) => Some( - ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new(codec::build_loot_roll_won( + Some(( + corpse_guid, + slot, + item_entry, + winning_roll, + winning_vote, + random_property_id, + )) => Some(ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new( + codec::build_loot_roll_won( corpse_guid, slot, item_entry, row.other_guid, winning_roll, winning_vote, - ))), - ), + random_property_id, + ), + ))), None => { log::warn!( "loot ROLL_WON relay: unparseable payload {:?} (event {})", @@ -3090,6 +3112,7 @@ fn item_gain_feedback( entry: u32, gained: u32, stack_add: bool, + random_property_id: u32, ) -> Vec { if gained == 0 { return Vec::new(); @@ -3102,6 +3125,7 @@ fn item_gain_feedback( entry, gained, stack_add, + random_property_id, )), ))]; let wanted = db.game_character_quest().iter().any(|q| { @@ -3147,6 +3171,7 @@ pub(crate) fn item_instance_insert_outbound( durability: row.durability, max_durability, container_slots, + random_property_id: row.random_property_id, }; out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_item_create_object(&view)), @@ -3156,7 +3181,9 @@ pub(crate) fn item_instance_insert_outbound( Box::new(values), ))); } - if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, row.entry) { + if let Some(values) = + codec::build_visible_item_values(self_guid, row.slot, row.entry, row.random_property_id) + { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(values), ))); @@ -3182,6 +3209,7 @@ pub(crate) fn item_instance_insert_outbound( row.entry, row.stack_count, false, + row.random_property_id, )); if row.slot <= 18 { append_item_armor_and_sheet(db, self_guid, &mut out); @@ -3201,7 +3229,7 @@ pub(crate) fn item_instance_delete_outbound( Box::new(values), ))); } - if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, 0) { + if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, 0, 0) { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(values), ))); @@ -3245,6 +3273,7 @@ pub(crate) fn item_instance_update_outbound( row.entry, row.stack_count - old.stack_count, true, + row.random_property_id, ) } else { Vec::new() @@ -3254,7 +3283,7 @@ pub(crate) fn item_instance_update_outbound( db.game_item_instance() .iter() .find(|item| item.owner_guid == self_guid && item.slot == slot) - .map(|item| (item.guid, item.entry)) + .map(|item| (item.guid, item.entry, item.random_property_id)) }); } if old.stack_count != row.stack_count || old.durability != row.durability { @@ -3282,30 +3311,32 @@ fn append_final_item_slots( old_slot: u8, new_slot: u8, out: &mut Vec, - item_in_slot: impl Fn(u8) -> Option<(u64, u32)>, + item_in_slot: impl Fn(u8) -> Option<(u64, u32, u32)>, ) { let old_item = item_in_slot(old_slot); let new_item = item_in_slot(new_slot).unwrap_or_default(); let slots = old_item .is_none() - .then_some((old_slot, (0, 0))) + .then_some((old_slot, (0, 0, 0))) .into_iter() .chain([(new_slot, new_item)]); - for (slot, (guid, _)) in slots.clone() { + for (slot, (guid, _, _)) in slots.clone() { if let Some(values) = codec::build_inv_slot_values(self_guid, slot, guid) { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(values), ))); } } - for (slot, (_, entry)) in slots.clone() { - if let Some(values) = codec::build_visible_item_values(self_guid, slot, entry) { + for (slot, (_, entry, random_property_id)) in slots.clone() { + if let Some(values) = + codec::build_visible_item_values(self_guid, slot, entry, random_property_id) + { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(values), ))); } } - for (slot, (guid, _)) in slots { + for (slot, (guid, _, _)) in slots { if let Some((bag_slot, slot_in_bag)) = bag_content_parts(slot) { if let Some((bag_guid, _)) = item_in_slot(bag_slot) { let (opcode, body) = @@ -4294,6 +4325,7 @@ mod tests { enchantment: 2564, durability: 34, max_durability: 40, + random_property_id: 0, }], ); let event = |k: u8, p: &str| TradeEvent { diff --git a/gateway/src/stdb/views.rs b/gateway/src/stdb/views.rs index e8664d4b0..f041a8c9b 100644 --- a/gateway/src/stdb/views.rs +++ b/gateway/src/stdb/views.rs @@ -103,6 +103,7 @@ pub(crate) fn item_template_view(t: ItemTemplate) -> crate::codec::ItemTemplateV bag_family: t.bag_family, allowed_class: t.allowed_class, allowed_race: t.allowed_race, + random_property: t.random_property, } } diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index d4c6c1056..4f99edd04 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -1073,6 +1073,7 @@ impl WorldStore for Coordinator { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, ) -> Result<()> { self.realm_loot_op( op, @@ -1083,6 +1084,7 @@ impl WorldStore for Coordinator { vote, deadline_micros, recipients, + random_property_id, ) } diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index 149c779f8..2c5e307bd 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -722,6 +722,7 @@ mod tests { highest_bidder_guid: 71, highest_bid: 201, expires_at_micros: 5_000_000, + random_property_id: 0, }], total: 51, now_micros: 1_000_000, @@ -777,6 +778,7 @@ mod tests { highest_bidder_guid: 12, highest_bid: 100, expires_at_micros: 4_000_000, + random_property_id: 0, }], total: 52, now_micros: 1_000_000, @@ -825,6 +827,7 @@ mod tests { highest_bidder_guid: 8, highest_bid: 107, expires_at_micros: 4_000_000, + random_property_id: 0, }], total: 52, now_micros: 1_000_000, diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 2794f4364..ead23a66f 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -111,7 +111,10 @@ fn enter_world( // client already has), and the (slot, guid) pairs seed the player's PLAYER_FIELD_INV_SLOT // descriptors. Empty for a character that owns nothing — login is otherwise unchanged. let items = store.player_items(character_guid).unwrap_or_default(); - let inventory: Vec<(u8, u64, u32)> = items.iter().map(|i| (i.slot, i.guid, i.entry)).collect(); + let inventory: Vec<(u8, u64, u32, u32)> = items + .iter() + .map(|i| (i.slot, i.guid, i.entry, i.random_property_id)) + .collect(); let learned = store .player_learned_spells(character_guid) .unwrap_or_default(); diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 57c552668..16f463c60 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -21,7 +21,7 @@ pub(crate) trait VendorActionStore: Send + Sync { ) -> Result<()>; /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` per entry (≤12). - fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32)>; + fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)>; /// Bag slot of the item instance with `item_guid`. Item guids are globally unique, so no /// owner check is needed here — the module reducer enforces ownership on the repair call. @@ -76,7 +76,7 @@ impl VendorActionStore for crate::stdb::Coordinator { ) } - fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32)> { + fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { crate::stdb::Coordinator::buyback_ring(self, player_guid) } @@ -391,13 +391,13 @@ pub(crate) fn build_buyback_view_replay( render_buyback_view(self_guid, &ring) } -fn render_buyback_view(self_guid: u64, ring: &[(u32, u32, u32)]) -> Vec { +fn render_buyback_view(self_guid: u64, ring: &[(u32, u32, u32, u32)]) -> Vec { let mut outbound = Vec::new(); let mut mask = codec::update_mask::UpdateMaskValues::new(); for i in 0..BUYBACK_SLOTS { let wire_slot = BUYBACK_WIRE_SLOT_BASE + i; let (fab_guid, price) = match ring.get(i as usize) { - Some(&(entry, count, price)) => { + Some(&(entry, count, price, random_property_id)) => { let fab_guid = 0x4090_0000_0000_0000u64 | u64::from(i); let view = codec::ItemInstanceView { guid: fab_guid, @@ -408,6 +408,7 @@ fn render_buyback_view(self_guid: u64, ring: &[(u32, u32, u32)]) -> Vec, gate_error: Option, buy_error: Option, - ring: Vec<(u32, u32, u32)>, + ring: Vec<(u32, u32, u32, u32)>, item_slots: Vec<(u64, u8)>, repair_error: Option, sell_error: Option, @@ -506,7 +507,7 @@ mod tests { } } - fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32)> { + fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { self.ring.clone() } @@ -979,11 +980,11 @@ mod tests { /// The descriptor half of the tab, rebuilt from the ring independently of the seam so the /// fabricated guids and the price/timestamp indices are pinned, not just echoed. - fn expected_values_update(ring: &[(u32, u32, u32)]) -> (u16, Vec) { + fn expected_values_update(ring: &[(u32, u32, u32, u32)]) -> (u16, Vec) { let mut mask = codec::update_mask::UpdateMaskValues::new(); for i in 0..12u16 { let (fab_guid, price) = match ring.get(i as usize) { - Some(&(_, _, price)) => (0x4090_0000_0000_0000u64 | u64::from(i), price), + Some(&(_, _, price, _)) => (0x4090_0000_0000_0000u64 | u64::from(i), price), None => (0, 0), }; mask.set_u64(486 + (69 + i) * 2, fab_guid); @@ -1004,13 +1005,14 @@ mod tests { durability: 0, max_durability: 0, container_slots: 0, + random_property_id: 0, }, ))) } - fn assert_renders_ring(outbound: &[Outbound], ring: &[(u32, u32, u32)]) { + fn assert_renders_ring(outbound: &[Outbound], ring: &[(u32, u32, u32, u32)]) { assert_eq!(outbound.len(), ring.len() + 1); - for (i, &(entry, count, _)) in ring.iter().enumerate() { + for (i, &(entry, count, _, _)) in ring.iter().enumerate() { let expected = expected_create(i as u16, entry, count); assert!( matches!(&outbound[i], Outbound::One(message) if *message == expected), @@ -1026,7 +1028,7 @@ mod tests { #[test] fn the_buyback_view_renders_one_fabricated_item_per_ring_entry_plus_the_descriptor_update() { - let ring = vec![(2589, 5, 120), (4540, 1, 30)]; + let ring = vec![(2589, 5, 120, 0), (4540, 1, 30, 0)]; let actions = InMemoryVendorActions { ring: ring.clone(), ..Default::default() @@ -1039,7 +1041,7 @@ mod tests { #[test] fn a_full_ring_renders_all_thirteen_wire_slots_from_the_shared_base() { - let ring: Vec<(u32, u32, u32)> = (0..12).map(|i| (100 + i, 1, 10 * i)).collect(); + let ring: Vec<(u32, u32, u32, u32)> = (0..12).map(|i| (100 + i, 1, 10 * i, 0)).collect(); let actions = InMemoryVendorActions { ring: ring.clone(), ..Default::default() @@ -1068,7 +1070,7 @@ mod tests { #[test] fn a_login_replay_of_a_persisted_ring_renders_it_like_an_in_session_refresh() { - let ring = vec![(2589, 5, 120)]; + let ring = vec![(2589, 5, 120, 0)]; let actions = InMemoryVendorActions { ring: ring.clone(), ..Default::default() @@ -1121,7 +1123,7 @@ mod tests { #[test] fn a_successful_buyback_returns_the_full_rebuilt_view() { - let ring = vec![(2589, 5, 120), (4540, 1, 30)]; + let ring = vec![(2589, 5, 120, 0), (4540, 1, 30, 0)]; let actions = InMemoryVendorActions { ring: ring.clone(), ..Default::default() @@ -1140,7 +1142,7 @@ mod tests { #[test] fn a_buyback_without_an_actor_falls_back_to_the_legacy_zero_actor_and_renders_no_view() { let actions = InMemoryVendorActions { - ring: vec![(2589, 5, 120)], + ring: vec![(2589, 5, 120, 0)], ..Default::default() }; let player = VendorActionPlayer { diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index 5b9d7e21a..580b2636f 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -101,6 +101,7 @@ pub struct PendingLootRoll { pub corpse_guid: u64, pub slot: u8, pub item_entry: u32, + pub random_property_id: u32, /// Preserved verbatim from the local row so promotion never restarts the 60s clock. pub deadline_micros: i64, /// Every eligible voter, snapshotted at kill time (`game_loot_roll_vote`'s rows for this roll). @@ -142,6 +143,7 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo 0, roll.deadline_micros, roll.recipients.clone(), + roll.random_property_id, ) { Ok(()) => { if let Err(e) = shard.clear_promoted_loot_roll(roll.roll_id) { diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index e08327346..66a6a2644 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -93,6 +93,7 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() item_entry: 1234, deadline_micros: 999, recipients: vec![GINGER], + random_property_id: 0, }]; let store = InMemoryStore { peers: std::sync::Mutex::new(vec![peer.clone()]), @@ -123,6 +124,7 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { item_entry: 1234, deadline_micros: 999_999, recipients: vec![GINGER, TRIN], + random_property_id: 0, }]; let mut watermark = 0u64; loot::relay_tick(world.as_ref(), &mut watermark); @@ -206,6 +208,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach item_entry: 1234, deadline_micros: 999, recipients: vec![GINGER], + random_property_id: 0, }]; // Vim leaving a 2-member party disbands it (vanilla: a party of one is no party) — the exact @@ -256,6 +259,7 @@ fn a_non_disband_capable_op_never_flushes_pending_rolls() { item_entry: 1234, deadline_micros: 999, recipients: vec![GINGER], + random_property_id: 0, }]; party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the invite runs"); assert!( diff --git a/gateway/src/world/mail.rs b/gateway/src/world/mail.rs index 0b1aade0f..77ebe6738 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -230,6 +230,7 @@ pub struct AttachedItem { pub durability: u32, pub enchant_id: u32, pub soulbound: bool, + pub random_property_id: u32, } impl AttachedItem { @@ -381,6 +382,7 @@ pub(crate) fn take_item( durability: row.item_durability, enchant_id: row.item_enchant_id, soulbound: row.item_soulbound, + random_property_id: row.random_property_id, }; if item.is_empty() { return Err(TakeItemRefusal::Other( diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index effbaef2d..a77dafe90 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -1771,6 +1771,7 @@ fn sword() -> mail::AttachedItem { durability: 42, enchant_id: 7, soulbound: false, + random_property_id: 0, } } fn give_item(shard: &InMemoryStore, owner: u64, guid: u64, item: mail::AttachedItem) { @@ -1860,6 +1861,7 @@ fn a_soulbound_attachment_is_refused_at_send_and_stays_in_the_senders_bags() { SWORD_GUID, mail::AttachedItem { soulbound: true, + random_property_id: 0, ..sword() }, ); diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index d844af395..04f648c86 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -260,6 +260,7 @@ pub trait WorldStore: _vote: u8, _deadline_micros: i64, _recipients: Vec, + _random_property_id: u32, ) -> Result<()> { Err(anyhow!("this store does not host realm-wide loot rolls")) } @@ -282,6 +283,7 @@ pub trait WorldStore: vote, 0, Vec::new(), + 0, )?; Ok(LootActionStatus::Applied) } diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index e325b0d90..d11abdde4 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -286,7 +286,7 @@ struct InMemoryStore { item_templates: Vec, /// The player's buyback ring as `(item_entry, stack_count, price)`; empty by default, so a /// fixture login replays no buyback tab. - buyback_ring: Vec<(u32, u32, u32)>, + buyback_ring: Vec<(u32, u32, u32, u32)>, /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. npc_refuses: bool, /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the @@ -407,7 +407,7 @@ struct InMemoryStore { /// What `talent_grant_spell` returns (0 = passive talent → no SMSG_LEARNED_SPELL push). talent_grant: u32, /// What `talent_pane_sync` returns: (teach rank-spell, superseded prev, points remaining). - talent_pane: (u32, u32, u32), + talent_pane: (u32, u32, u32, u32), /// What `superseded_old_rank` returns for a trainer buy — the known previous rank a /// non-stacking chain's new rank replaces. `None` (derive-Default) mirrors "no known prior /// rank" -> a trainer buy pushes plain SMSG_LEARNED_SPELL. @@ -878,6 +878,7 @@ impl InMemoryStore { item_durability: item.durability, item_enchant_id: item.enchant_id, item_soulbound: item.soulbound, + random_property_id: item.random_property_id, created_at_secs: 1_000, ..Default::default() }, @@ -1465,6 +1466,7 @@ impl WorldStore for InMemoryStore { durability: m.item_durability, enchant_id: m.item_enchant_id, soulbound: m.item_soulbound, + random_property_id: m.random_property_id, }, lyracore_shared::mail::cod_settlement( m.cod, @@ -1733,6 +1735,7 @@ impl WorldStore for InMemoryStore { durability: m.item_durability, enchant_id: m.item_enchant_id, soulbound: m.item_soulbound, + random_property_id: m.random_property_id, }; m.item_entry = 0; m.item_stack_count = 0; @@ -1909,7 +1912,7 @@ impl WorldStore for InMemoryStore { ) -> Result<()> { Ok(()) } - fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { + fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32, u32) { self.talent_pane } fn talent_points_spent(&self, _character_guid: u64) -> u32 { @@ -2618,6 +2621,7 @@ impl WorldStore for InMemoryStore { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, ) -> Result<()> { self.rec("realm_loot_op"); self.realm_loot_ops.lock().unwrap().push(( @@ -2652,6 +2656,7 @@ impl WorldStore for InMemoryStore { vote, 0, Vec::new(), + 0, )?; if let Some(failure) = &self.loot_action_failure { return Err(anyhow!(failure.clone())); @@ -3109,6 +3114,7 @@ impl QuestActionStore for InMemoryStore { item.entry, item.stack_count, false, + 0, )), ))); tx.send(Outbound::Job(Box::new(move || relay))) @@ -3141,7 +3147,7 @@ impl VendorActionStore for InMemoryStore { } } - fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32)> { + fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { self.buyback_ring.clone() } @@ -4685,6 +4691,7 @@ fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { durability: 20, max_durability: 20, container_slots: 0, + random_property_id: 0, }], reputations: vec![(19, 3175, false)], ..tester_store(7) @@ -6014,6 +6021,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { durability: 20, max_durability: 20, container_slots: 0, + random_property_id: 0, }; s.turn_in_reward_item = Some(reward_item.clone()); let store = std::sync::Arc::new(s); @@ -6053,6 +6061,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { reward_item.entry, reward_item.stack_count, false, + 0, ), ))), framed(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_COMPLETE( diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index df74131e1..ac6c35dee 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -204,6 +204,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let (cbi_stmts, cbi_count) = char_base_info_sql(&mut chain)?; let (race_stmts, race_count) = race_info_sql(&races); let (si_stmts, si_count) = start_item_sql(&mut chain)?; + let (property_stmts, _) = crate::item_property::catalogue_sql(&mut chain)?; // Areas / area triggers / graveyards (work-item 209): AreaTable.dbc → game_area, AreaTrigger.dbc // → game_area_trigger, WorldSafeLocs.dbc → game_graveyard. All small tables — load ALL rows (no @@ -269,6 +270,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let (sav_stmts, sav_count) = skill_availability_sql(&skill_availability); if args.apply { + crate::run_sql_statements(args, &property_stmts, "itemproperties")?; crate::run_sql_statements(args, &faction_stmts, "faction")?; eprintln!("dbc: loaded {fn_count} faction templates into game_faction_template."); crate::run_sql_statements(args, &gf_stmts, "game_faction")?; diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs new file mode 100644 index 000000000..83b1c467e --- /dev/null +++ b/importer/src/item_property.rs @@ -0,0 +1,498 @@ +//! Derive Random Property catalogues from the Operator's client and ClassicDB dump. + +use crate::{dbc, field, parse_table, push_insert, sql_text}; +use anyhow::{bail, Context, Result}; +use lyracore_shared::item_property as kind; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use wow_dbc::vanilla_tables::{ + item_random_properties::ItemRandomProperties, + spell::{Spell, SpellRow}, + spell_item_enchantment::SpellItemEnchantment, +}; +use wow_dbc::DbcTable; +use wow_mpq::PatchChain; + +#[derive(Debug, PartialEq)] +struct Effect { + index: u8, + kind: u8, + amount: i32, + spell_id: u32, + school_mask: u32, +} + +fn resistance_kind(school: u32) -> Option { + match school { + 0 => Some(kind::ARMOR), + 1..=6 => Some(kind::HOLY_RESISTANCE + school as u8 - 1), + _ => None, + } +} + +/// Flat equip auras only. Conditional, scaling and percentage modifiers retain kind 0. +fn aura_kinds(aura: u32, misc: u32) -> Vec<(u8, u32)> { + match (aura, misc) { + (29, u32::MAX) => (kind::STRENGTH..=kind::SPIRIT) + .map(|kind| (kind, 0)) + .collect(), + (29, 0..=4) => vec![(kind::STRENGTH + misc as u8, 0)], + (22, mask) if mask > 0 && mask & !0x7f == 0 => (0..7) + .filter(|school| mask & (1 << school) != 0) + .map(|school| (resistance_kind(school).unwrap(), 1 << school)) + .collect(), + (34, _) => vec![(kind::HEALTH, 0)], + (35, 0) => vec![(kind::MANA, 0)], + (13, mask) if mask > 0 && mask & !0x7f == 0 => { + let mut kinds = Vec::new(); + if mask & 1 != 0 { + kinds.push((kind::WEAPON_DAMAGE, 1)); + } + if mask & 0x7e != 0 { + kinds.push((kind::SPELL_POWER, mask & 0x7e)); + } + kinds + } + (135, _) => vec![(kind::HEALING_POWER, 0)], + (84, _) => vec![(kind::HEALTH_PER_FIVE, 0)], + (85, 0) => vec![(kind::MANA_PER_FIVE, 0)], + (52, _) => vec![(kind::CRIT, 0)], + (54, _) => vec![(kind::HIT, 0)], + (30, 95) => vec![(kind::DEFENSE, 0)], + (49, _) => vec![(kind::DODGE, 0)], + (47, _) => vec![(kind::PARRY, 0)], + (51, _) => vec![(kind::BLOCK, 0)], + _ => vec![(kind::UNKNOWN, 0)], + } +} + +fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { + let mut mapped = Vec::new(); + for subeffect in 0..3 { + if spell.effect[subeffect] == 0 { + continue; + } + let aura = spell.effect_aura[subeffect] as u32; + let flat = spell.effect[subeffect] == 6 + && spell.effect_real_points_per_level[subeffect] == 0.0 + && spell.effect_dice_per_level[subeffect] == 0.0 + && spell.effect_points_per_combo[subeffect] == 0.0 + && spell.equipped_item_class.id == u32::MAX; + let kinds = if flat { + aura_kinds(aura, spell.effect_misc_value[subeffect]) + } else { + vec![(kind::UNKNOWN, 0)] + }; + let amount = spell.effect_base_points[subeffect] + .checked_add(1) + .context("enchantment spell amount overflows")?; + for (expanded, (kind, school_mask)) in kinds.into_iter().enumerate() { + let amount = match kind { + kind::CRIT | kind::HIT | kind::DODGE | kind::PARRY | kind::BLOCK => amount + .checked_mul(100) + .context("enchantment basis points overflow")?, + _ => amount, + }; + mapped.push(Effect { + index: slot * 64 + subeffect as u8 * 8 + expanded as u8, + kind, + amount, + spell_id: spell.id.id as u32, + school_mask, + }); + } + } + if mapped.is_empty() { + mapped.push(Effect { + index: slot * 64, + kind: kind::UNKNOWN, + amount: 0, + spell_id: spell.id.id as u32, + school_mask: 0, + }); + } + Ok(mapped) +} + +pub(crate) fn catalogue_sql(chain: &mut PatchChain) -> Result<(Vec, BTreeSet)> { + let properties: ItemRandomProperties = dbc::read_table(chain)?; + let enchantments: SpellItemEnchantment = dbc::read_table(chain)?; + let spells: Spell = dbc::read_table(chain)?; + catalogue_rows_sql(&properties, &enchantments, &spells) +} + +fn catalogue_rows_sql( + properties: &ItemRandomProperties, + enchantments: &SpellItemEnchantment, + spells: &Spell, +) -> Result<(Vec, BTreeSet)> { + for (name, count) in [ + ("ItemRandomProperties.dbc", properties.rows().len()), + ("SpellItemEnchantment.dbc", enchantments.rows().len()), + ("Spell.dbc", spells.rows().len()), + ] { + if count == 0 { + bail!("{name} contains no rows; item catalogues were not changed"); + } + } + let spells: HashMap<_, _> = spells + .rows() + .iter() + .map(|row| (row.id.id as u32, row)) + .collect(); + let enchant_ids: BTreeSet<_> = enchantments + .rows() + .iter() + .map(|row| row.id.id as u32) + .collect(); + for (id, _, _) in kind::COMPATIBILITY_ENCHANTMENTS { + if enchant_ids.contains(&id) { + bail!("client enchantment {id} collides with an authored compatibility entry"); + } + } + let mut property_rows = BTreeMap::new(); + for property in properties.rows() { + let id = property.id.id as u32; + if id == 0 { + bail!("Random Property ID zero is reserved for plain items"); + } + if property.spell_item_enchantment[3..] + .iter() + .any(|id| *id != 0) + { + bail!("Random Property {id} has more than three enchantments"); + } + for enchant in property + .spell_item_enchantment + .into_iter() + .filter(|id| *id != 0) + { + if !enchant_ids.contains(&enchant) { + bail!("Random Property {id} references missing enchantment {enchant}"); + } + } + let [first, second, third, _, _] = property.spell_item_enchantment; + if property_rows + .insert( + id, + format!( + "({id},{first},{second},{third},{})", + sql_text(&property.suffix.en_gb) + ), + ) + .is_some() + { + bail!("duplicate Random Property {id}"); + } + } + let mut enchant_rows = BTreeMap::new(); + for enchant in enchantments.rows() { + let enchant_id = enchant.id.id as u32; + let mut effects = Vec::new(); + for slot in 0..3 { + let source_kind = enchant.enchantment_type[slot]; + if source_kind == 0 { + continue; + } + let argument = enchant.effect_arg[slot]; + if matches!(source_kind, 1 | 3) && !spells.contains_key(&(argument as u32)) { + bail!("enchantment {enchant_id} slot {slot} references missing spell {argument}"); + } + if source_kind == 3 { + effects.extend(spell_effects(slot as u8, spells[&(argument as u32)])?); + continue; + } + let kind = match source_kind { + 2 => kind::WEAPON_DAMAGE, + 4 => resistance_kind(argument as u32).unwrap_or(kind::UNKNOWN), + 5 => match argument { + 0 => kind::MANA, + 1 => kind::HEALTH, + 3 => kind::AGILITY, + 4 => kind::STRENGTH, + 5 => kind::INTELLECT, + 6 => kind::SPIRIT, + 7 => kind::STAMINA, + _ => kind::UNKNOWN, + }, + _ => kind::UNKNOWN, + }; + effects.push(Effect { + index: slot as u8 * 64, + kind, + amount: enchant.effect_points_min[slot], + spell_id: if source_kind == 1 { argument as u32 } else { 0 }, + school_mask: 0, + }); + } + if effects.is_empty() { + effects.push(Effect { + index: 0, + kind: kind::UNKNOWN, + amount: 0, + spell_id: 0, + school_mask: 0, + }); + } + for effect in effects { + insert_effect(&mut enchant_rows, enchant_id, effect)?; + } + } + for (id, kind, amount) in kind::COMPATIBILITY_ENCHANTMENTS { + insert_effect( + &mut enchant_rows, + id, + Effect { + index: 0, + kind, + amount, + spell_id: 0, + school_mask: 0, + }, + )?; + } + eprintln!("Random Properties: {} properties, {} normalized enchantment effects, including two authored compatibility entries", property_rows.len(), enchant_rows.len()); + let ids = property_rows.keys().copied().collect(); + let mut sql = vec![ + "DELETE FROM game_item_random_property WHERE property_id > 0".into(), + "DELETE FROM game_item_enchantment WHERE id > 0".into(), + ]; + push_insert( + &mut sql, + "game_item_random_property", + "property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix", + &property_rows.into_values().collect::>(), + ); + push_insert( + &mut sql, + "game_item_enchantment", + "id,enchant_id,effect_index,kind,amount,spell_id,school_mask", + &enchant_rows.into_values().collect::>(), + ); + Ok((sql, ids)) +} + +fn insert_effect(rows: &mut BTreeMap, enchant_id: u32, effect: Effect) -> Result<()> { + let id = (u64::from(enchant_id) << 8) | u64::from(effect.index); + if rows + .insert( + id, + format!( + "({id},{enchant_id},{},{},{},{},{})", + effect.index, effect.kind, effect.amount, effect.spell_id, effect.school_mask + ), + ) + .is_some() + { + bail!("duplicate enchantment effect {id}"); + } + Ok(()) +} + +/// ClassicDB chance has at most two decimal places. Keep hundredths exactly; reject extra precision. +fn decimal_weight(value: &str) -> Result { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || fraction.len() > 2 + || !fraction.bytes().all(|b| b.is_ascii_digit()) + { + bail!("invalid property weight {value}"); + } + let whole: u32 = whole.parse()?; + let fraction: u32 = if fraction.is_empty() { + 0 + } else { + fraction.parse::()? * if fraction.len() == 1 { 10 } else { 1 } + }; + let weight = whole + .checked_mul(100) + .and_then(|n| n.checked_add(fraction)) + .context("property weight overflows")?; + if weight == 0 { + bail!("property weight must be positive"); + } + Ok(weight) +} + +pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> { + let raw = parse_table(dump, "item_enchantment_template"); + let required: BTreeSet = parse_table(dump, "item_template") + .iter() + .map(|row| { + field(row, crate::it::RANDOM_PROPERTY) + .parse::() + .unwrap_or(0) + }) + .filter(|id| *id != 0) + .collect(); + if raw.is_empty() && required.is_empty() { + return Ok(Vec::new()); + } + let dir = dbc_dir.context( + "Random Properties require --dbc to check pool and enchantment references before apply", + )?; + let mut chain = dbc::open_chain(std::path::Path::new(dir))?; + let (mut sql, properties) = catalogue_sql(&mut chain)?; + let mut rows = BTreeMap::new(); + let mut pools = BTreeSet::new(); + for row in raw { + let pool: u32 = field(&row, 0).parse().context("invalid Property Pool ID")?; + let property: u32 = field(&row, 1) + .parse() + .context("invalid Random Property ID")?; + if pool == 0 || !properties.contains(&property) { + bail!("pool {pool} references missing Random Property {property}"); + } + let weight = decimal_weight(field(&row, 2))?; + let id = (u64::from(pool) << 32) | u64::from(property); + if rows + .insert(id, format!("({id},{pool},{property},{weight})")) + .is_some() + { + bail!("duplicate property {property} in pool {pool}"); + } + pools.insert(pool); + } + for pool in required.difference(&pools) { + bail!("item template references missing Property Pool {pool}"); + } + eprintln!( + "Random Properties: {} members in {} pools", + rows.len(), + pools.len() + ); + sql.push("DELETE FROM game_item_property_weight WHERE id > 0".into()); + push_insert( + &mut sql, + "game_item_property_weight", + "id,pool_id,property_id,weight", + &rows.into_values().collect::>(), + ); + Ok(sql) +} + +#[cfg(test)] +mod tests { + use super::*; + // Authored zero records let tests exercise the real DBC parser without client bytes. + fn synthetic_table(fields: u32) -> T { + let mut bytes = b"WDBC".to_vec(); + for value in [1, fields, fields * 4, 1] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.resize(20 + fields as usize * 4 + 1, 0); + T::read(&mut std::io::Cursor::new(bytes)).unwrap() + } + + fn catalogues() -> (ItemRandomProperties, SpellItemEnchantment, Spell) { + let mut properties: ItemRandomProperties = synthetic_table(16); + properties.rows[0].id.id = 509_0001; + properties.rows[0].spell_item_enchantment = [509_0002, 0, 0, 0, 0]; + properties.rows[0].suffix.en_gb = "of the Fixture".to_owned(); + let mut enchantments: SpellItemEnchantment = synthetic_table(24); + enchantments.rows[0].id.id = 509_0002; + enchantments.rows[0].enchantment_type = [5, 5, 77]; + enchantments.rows[0].effect_arg = [7, 4, 0]; + enchantments.rows[0].effect_points_min = [7, 9, 11]; + (properties, enchantments, synthetic_table(173)) + } + + #[test] + fn catalogue_keeps_all_effect_slots_unknowns_and_compatibility_amounts() { + let (properties, enchantments, spells) = catalogues(); + let (sql, ids) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); + let sql = sql.join("\n"); + assert_eq!(ids, BTreeSet::from([509_0001])); + for row in [ + "(5090001,5090002,0,0,'of the Fixture')", + "(1303040512,5090002,0,3,7,0,0)", + "(1303040576,5090002,64,1,9,0,0)", + "(1303040640,5090002,128,0,11,0,0)", + "(1982720,7745,0,1,3,0,0)", + "(1983488,7748,0,3,3,0,0)", + ] { + assert!(sql.contains(row), "missing {row}: {sql}"); + } + } + + #[test] + fn empty_or_inconsistent_catalogues_produce_no_import_statements() { + for missing in 0..3 { + let (mut properties, mut enchantments, mut spells) = catalogues(); + match missing { + 0 => properties.rows.clear(), + 1 => enchantments.rows.clear(), + _ => spells.rows.clear(), + } + assert!(catalogue_rows_sql(&properties, &enchantments, &spells).is_err()); + } + let (mut properties, mut enchantments, spells) = catalogues(); + properties.rows[0].spell_item_enchantment[1] = 509_0099; + assert!(catalogue_rows_sql(&properties, &enchantments, &spells).is_err()); + properties.rows[0].spell_item_enchantment[1] = 0; + enchantments.rows[0].id.id = 7745; + assert!(catalogue_rows_sql(&properties, &enchantments, &spells) + .unwrap_err() + .to_string() + .contains("collides")); + } + + #[test] + fn equip_spell_expands_stats_and_retains_unmapped_effects() { + let mut spells: Spell = synthetic_table(173); + let spell = &mut spells.rows[0]; + spell.id.id = 509_0003; + spell.equipped_item_class.id = u32::MAX; + spell.effect = [6, 6, 2]; + spell.effect_aura[0] = 29u32.try_into().unwrap(); + spell.effect_misc_value[0] = u32::MAX; + spell.effect_base_points = [6, 8, 10]; + let mapped = spell_effects(1, spell).unwrap(); + assert_eq!(mapped.len(), 7); + assert_eq!( + mapped.iter().map(|e| e.kind).collect::>(), + [1, 2, 3, 4, 5, 0, 0] + ); + assert_eq!( + mapped.iter().map(|e| e.amount).collect::>(), + [7, 7, 7, 7, 7, 9, 11] + ); + assert_eq!( + mapped.iter().map(|e| e.index).collect::>(), + [64, 65, 66, 67, 68, 72, 80] + ); + assert!(mapped.iter().all(|e| e.spell_id == 509_0003)); + } + + #[test] + fn decimal_weights_keep_exact_hundredths() { + assert_eq!(decimal_weight("0.01").unwrap(), 1); + assert_eq!(decimal_weight("99.52").unwrap(), 9952); + assert_eq!(decimal_weight("131.01").unwrap(), 13101); + assert_eq!(decimal_weight("1.2").unwrap(), 120); + for value in ["0", "-1", "NaN", "1.001", "1e2"] { + assert!(decimal_weight(value).is_err()); + } + } + #[test] + fn primary_stats_and_multischool_resistance_keep_all_contributions() { + assert_eq!(aura_kinds(29, 2), vec![(kind::STAMINA, 0)]); + assert_eq!( + aura_kinds(29, u32::MAX), + vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 0)] + ); + assert_eq!( + aura_kinds(22, 0x7e), + vec![(8, 2), (9, 4), (10, 8), (11, 16), (12, 32), (13, 64)] + ); + assert_eq!(aura_kinds(13, 16), vec![(kind::SPELL_POWER, 16)]); + assert_eq!(aura_kinds(999, 0), vec![(kind::UNKNOWN, 0)]); + } + #[test] + fn authored_enchants_keep_existing_stored_meanings() { + assert_eq!( + kind::COMPATIBILITY_ENCHANTMENTS, + [(7745, kind::STRENGTH, 3), (7748, kind::STAMINA, 3)] + ); + } +} diff --git a/importer/src/main.rs b/importer/src/main.rs index e5b25f569..e1477c76b 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -54,6 +54,7 @@ mod dbc; mod eventai; mod eventai_presentation; mod go_model; +mod item_property; mod nav; mod pack_client; mod package_delta; @@ -409,6 +410,7 @@ mod it { pub const START_QUEST: usize = 110; // quest-starter link (work-item 213: 194 consumes) pub const SHEATH: usize = 113; // sheath posture, sent verbatim to the client (0=no stow, 1H sword=3, // shield=4 — opaque client indices, see items/tables.rs::sheath) + pub const RANDOM_PROPERTY: usize = 114; pub const BLOCK: usize = 115; // shield block value (CREATE TABLE: …RandomProperty(114), block(115), itemset(116)) pub const MAX_DURABILITY: usize = 117; // BagFamily (work-item 213): bag-type restriction bitmask. Anchored right after the @@ -3327,7 +3329,7 @@ fn build_items_and_loot( // non-shields) — makes imported shields actually block (combat::effective_block_value reads it). // restores_power (drink) is a Bool → bare true/false SQL literal (the is_negative Bool-SQL note). item_rows.push(format!( - "({entry},{class},{subclass},{name},{disp},{qual},{inv},{ilvl},{rlvl},{dur},{buy},{sell},{stack},{dmin},{dmax},{delay},{s_str},{s_agi},{s_sta},{s_int},{s_spi},0,0,{armor},{block},{drink},{sp1},{spt1},{sp2},{spt2},{cslots},{sheath},{bonding},{holy},{fire},{nature},{frost},{shadow},{arcane},{sp3},{spt3},{sp4},{spt4},{sp5},{spt5},{req_skill},{req_skill_rank},{req_rep_faction},{req_rep_rank},{max_count},{item_flags},{page_text},{start_quest},{bag_family},{buy_count},{food_type},{allowed_class},{allowed_race})", + "({entry},{class},{subclass},{name},{disp},{qual},{inv},{ilvl},{rlvl},{dur},{buy},{sell},{stack},{dmin},{dmax},{delay},{s_str},{s_agi},{s_sta},{s_int},{s_spi},0,0,{armor},{block},{drink},{sp1},{spt1},{sp2},{spt2},{cslots},{sheath},{bonding},{holy},{fire},{nature},{frost},{shadow},{arcane},{sp3},{spt3},{sp4},{spt4},{sp5},{spt5},{req_skill},{req_skill_rank},{req_rep_faction},{req_rep_rank},{max_count},{item_flags},{page_text},{start_quest},{bag_family},{buy_count},{food_type},{allowed_class},{allowed_race},{random_property})", sheath = field(&row, it::SHEATH).parse::().unwrap_or(0), sp2 = field(&row, it::SPELLID_2).parse::().unwrap_or(0), spt2 = field(&row, it::SPELLTRIGGER_2).parse::().unwrap_or(0), @@ -3354,6 +3356,7 @@ fn build_items_and_loot( delay = field(&row, it::DELAY), armor = field(&row, it::ARMOR), block = field(&row, it::BLOCK), + random_property = field(&row, it::RANDOM_PROPERTY).parse::().unwrap_or(0), allowed_class = allowed_class, allowed_race = allowed_race, )); @@ -3862,6 +3865,9 @@ fn build_dump_plan( // the `import_creature_spawns` reducer (step 6), which clears every CREATURE entity + spawn // and loads the new spawns with a valid `ctx.timestamp`. let mut stmts: Vec = Vec::new(); + if family_active(args, "items") { + stmts.extend(item_property::dump_sql(dump, args.dbc.as_deref())?); + } push_world_content_statements(args, content, &mut stmts); push_quest_and_gameobject_statements(args, content, &mut stmts); push_creature_behaviour_statements(args, content, &mut stmts); @@ -5047,7 +5053,7 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); - push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", item_rows); + push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race,random_property", item_rows); push_insert( stmts, "game_creature_loot", @@ -6186,6 +6192,7 @@ mod tests { cols[it::MAX_DURABILITY] = "80".into(); cols[it::BAG_FAMILY] = "6".into(); cols[it::FOOD_TYPE] = "1".into(); + cols[it::RANDOM_PROPERTY] = "5090100".into(); let tuple = cols .join(",") @@ -6197,7 +6204,7 @@ mod tests { assert_eq!(item_rows.len(), 1); assert_eq!( item_rows[0], - "(90001,4,1,'Ring of Fire Resistance',1234,3,5,40,30,80,1000,200,1,0,0,0,0,0,0,0,0,0,0,55,0,false,0,0,0,0,0,0,2,3,12,0,0,0,0,12345,1,12346,2,12347,0,165,150,69,3,7,512,999,777,6,1,1,1503,255)", + "(90001,4,1,'Ring of Fire Resistance',1234,3,5,40,30,80,1000,200,1,0,0,0,0,0,0,0,0,0,0,55,0,false,0,0,0,0,0,0,2,3,12,0,0,0,0,12345,1,12346,2,12347,0,165,150,69,3,7,512,999,777,6,1,1,1503,255,5090100)", ); } @@ -6236,7 +6243,7 @@ mod tests { assert_eq!(item_rows.len(), 1); assert_eq!( item_rows[0], - "(25,2,7,'Worn Shortsword',1521,0,21,1,1,35,100,20,1,1,3,1800,0,0,0,0,0,0,0,0,0,false,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0)", + "(25,2,7,'Worn Shortsword',1521,0,21,1,1,35,100,20,1,1,3,1800,0,0,0,0,0,0,0,0,0,false,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0)", ); } @@ -6257,7 +6264,7 @@ mod tests { assert_eq!(item_rows.len(), 1); assert!( - item_rows[0].ends_with(",2147483650,2147483652)"), + item_rows[0].ends_with(",2147483650,2147483652,0)"), "restrictive masks, including unknown high bits, must remain unsigned and verbatim" ); } diff --git a/module/src/auction.rs b/module/src/auction.rs index 143603989..ac24641b4 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -54,6 +54,8 @@ pub struct Auction { pub revision: u64, pub deposit_rate: u32, pub consignment_rate: u32, + #[default(0)] + pub random_property_id: u32, } /// Source-shard value reserved by a sharded listing operation. An active operation receipt makes @@ -83,6 +85,8 @@ pub struct AuctionHold { pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, + #[default(0)] + pub random_property_id: u32, } /// Durable idempotency receipt. The full listing payload makes identical replay distinguishable @@ -112,6 +116,8 @@ pub struct AuctionOperationReceipt { pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, + #[default(0)] + pub random_property_id: u32, } /// Source-shard copper fence for one caller-identified bid. `outcome == 0` is pending; every @@ -1166,6 +1172,7 @@ fn listing_from_hold(row: AuctionHold) -> PreparedListing { durability: row.item_durability, enchant_id: row.item_enchant_id, soulbound: row.item_soulbound, + random_property_id: row.random_property_id, }, deposit: row.deposit, created_micros: row.created_micros, @@ -1183,6 +1190,7 @@ fn hold_from_listing(listing: PreparedListing) -> AuctionHold { item_durability: listing.snapshot.durability, item_enchant_id: listing.snapshot.enchant_id, item_soulbound: listing.snapshot.soulbound, + random_property_id: listing.snapshot.random_property_id, start_bid: listing.request.terms.start_bid, buyout: listing.request.terms.buyout, duration_minutes: listing.request.terms.duration_minutes, @@ -1219,6 +1227,7 @@ fn listing_from_receipt(row: AuctionOperationReceipt) -> ListingReceipt { durability: row.item_durability, enchant_id: row.item_enchant_id, soulbound: row.item_soulbound, + random_property_id: row.random_property_id, }, deposit: row.deposit, created_micros: row.created_micros, @@ -1239,6 +1248,7 @@ fn receipt_from_listing(listing: PreparedListing, auction_id: u32) -> AuctionOpe item_durability: listing.snapshot.durability, item_enchant_id: listing.snapshot.enchant_id, item_soulbound: listing.snapshot.soulbound, + random_property_id: listing.snapshot.random_property_id, start_bid: listing.request.terms.start_bid, buyout: listing.request.terms.buyout, duration_minutes: listing.request.terms.duration_minutes, @@ -1293,6 +1303,7 @@ fn insert_active_auction(ctx: &ReducerContext, listing: &PreparedListing) -> u32 item_durability: listing.snapshot.durability, item_enchant_id: listing.snapshot.enchant_id, item_soulbound: listing.snapshot.soulbound, + random_property_id: listing.snapshot.random_property_id, start_bid: listing.request.terms.start_bid, buyout: listing.request.terms.buyout, highest_bidder_guid: 0, @@ -1596,6 +1607,7 @@ impl BidMarket for CtxBidMarket<'_> { durability: auction.item_durability, enchant_id: auction.item_enchant_id, soulbound: auction.item_soulbound, + random_property_id: auction.random_property_id, }, highest_bidder_guid: auction.highest_bidder_guid, highest_bid: auction.highest_bid, @@ -1986,6 +1998,7 @@ pub fn realm_auction_commit_listing( item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -2019,6 +2032,7 @@ pub fn realm_auction_commit_listing( durability: item_durability, enchant_id: item_enchant_id, soulbound: item_soulbound, + random_property_id, }, deposit, created_micros, @@ -2081,6 +2095,7 @@ pub fn realm_auction_refund_listing( item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -2103,6 +2118,7 @@ pub fn realm_auction_refund_listing( item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, @@ -2386,6 +2402,7 @@ pub fn debug_stage_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), St item_durability: 17, item_enchant_id: 9, item_soulbound: false, + random_property_id: 117, start_bid: 100, buyout: 500, highest_bidder_guid: BUYOUT_FIXTURE_DISPLACED_GUID, @@ -2490,6 +2507,7 @@ pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), S durability: 17, enchant_id: 9, soulbound: false, + random_property_id: 117, }) { return Err("winner item mail changed".to_string()); @@ -2523,6 +2541,7 @@ const EXPIRY_FIXTURE_ITEM: crate::items::ItemSnapshot = crate::items::ItemSnapsh durability: 17, enchant_id: 9, soulbound: false, + random_property_id: 117, }; /// Stage a valid winning-bid Auction whose one-shot schedule fires shortly after this transaction. @@ -2576,6 +2595,7 @@ pub fn debug_stage_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), St item_durability: EXPIRY_FIXTURE_ITEM.durability, item_enchant_id: EXPIRY_FIXTURE_ITEM.enchant_id, item_soulbound: EXPIRY_FIXTURE_ITEM.soulbound, + random_property_id: EXPIRY_FIXTURE_ITEM.random_property_id, start_bid: 100, buyout: 500, duration_minutes: 720, @@ -2597,6 +2617,7 @@ pub fn debug_stage_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), St item_durability: EXPIRY_FIXTURE_ITEM.durability, item_enchant_id: EXPIRY_FIXTURE_ITEM.enchant_id, item_soulbound: EXPIRY_FIXTURE_ITEM.soulbound, + random_property_id: EXPIRY_FIXTURE_ITEM.random_property_id, start_bid: 100, buyout: 500, highest_bidder_guid: EXPIRY_FIXTURE_WINNER_GUID, @@ -2792,6 +2813,7 @@ mod tests { durability: 17, enchant_id: 9, soulbound: false, + random_property_id: 117, }, sell_price: 100, } diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 1129a1086..6ce399841 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1286,6 +1286,11 @@ fn equip_into( let e = crate::helpers::live_entity(ctx, character_guid)?; let guid = crate::items::item_guid_for(character_guid, slot); let tmpl = ctx.db.game_item_template().entry().find(item_entry); + let random_property_id = tmpl + .as_ref() + .map(|t| crate::items::select_property(ctx, t)) + .transpose()? + .unwrap_or(0); let durability = tmpl.as_ref().map(|t| t.max_durability).unwrap_or(0); // This helper places the item DIRECTLY on the body (bypassing the pickup->equip two-step apply_item_move // drives), so either a BoP or a BoE template binds immediately here — mirroring "the item is now worn". @@ -1295,6 +1300,9 @@ fn equip_into( .unwrap_or(false); let instances = ctx.db.game_item_instance(); if let Some(mut inst) = instances.guid().find(guid) { + if inst.entry != item_entry { + inst.random_property_id = random_property_id; + } inst.entry = item_entry; inst.durability = durability; inst.soulbound = inst.soulbound || soulbound; @@ -1311,6 +1319,7 @@ fn equip_into( created_at: ctx.timestamp, enchant_id: 0, // debug-granted item — unenchanted soulbound, + random_property_id, }); } Ok(()) diff --git a/module/src/debug/repair.rs b/module/src/debug/repair.rs index e08d1f054..2cafa6ec5 100644 --- a/module/src/debug/repair.rs +++ b/module/src/debug/repair.rs @@ -58,6 +58,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { let createinfo = ctx.db.game_createinfo_spell().count(); crate::seed::seed_spell_groups(ctx); + crate::items::seed_compatibility_enchantments(ctx); let spell_groups = ctx.db.game_spell_group().count(); // The live stacking-family probe needs its four family members present on an already-migrated diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 5a4c48aef..68548b2df 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -812,6 +812,7 @@ pub(crate) fn apply_use_gameobject( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: crate::items::select_loot_property(ctx, tmpl.data0)?, }); } } else { @@ -827,6 +828,7 @@ pub(crate) fn apply_use_gameobject( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: crate::items::select_loot_property(ctx, item_entry)?, }); } } diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index 669493b1f..085d7836f 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -323,6 +323,7 @@ pub(crate) fn apply_item_sell( stack_count: inst.stack_count, price: earned, soulbound: inst.soulbound, + random_property_id: inst.random_property_id, }); instances.guid().delete(inst.guid); Ok(()) @@ -381,6 +382,7 @@ pub(crate) fn apply_buyback_item( &tmpl, entry.stack_count, entry.soulbound, + Some(entry.random_property_id), )?; buyback_tab.id().delete(entry.id); Ok(()) @@ -460,7 +462,7 @@ pub(crate) fn apply_buy_item( player.money = player.money.saturating_sub(cost); let owner_identity = player.owner_identity; ctx.db.game_world_entity().guid().update(player); - store_item(ctx, player_guid, owner_identity, &tmpl, count, false) + store_item(ctx, player_guid, owner_identity, &tmpl, count, false, None) } /// Repair the item in `slot` back to its template's `max_durability` — the FREE restore the harness diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index c0d426e76..79e9eb65b 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -75,6 +75,7 @@ pub(crate) fn apply_item_split( let entry = inst.entry; let owner_identity = inst.owner_identity; let durability = inst.durability; + let random_property_id = inst.random_property_id; let soulbound = inst.soulbound; // the split half carries the SAME binding state as its source stack instances.guid().update(inst); let new_guid = next_item_guid(ctx, player_guid, to_slot); @@ -89,6 +90,7 @@ pub(crate) fn apply_item_split( created_at: ctx.timestamp, enchant_id: 0, // a split is only ever on a stackable (non-equippable) item → never enchanted soulbound, + random_property_id, }); Ok(()) } @@ -257,7 +259,7 @@ pub(crate) fn apply_item_move( // MERGE only when dropping onto the SAME entry AND the item is stackable (max_stack > 1). // A missing template can't be merged (we can't know max_stack) — fall through to SWAP, which // needs no template, so the move never wedges on unseeded item data. - if dst.entry == src.entry { + if dst.entry == src.entry && dst.random_property_id == src.random_property_id { if let Some(tmpl) = ctx.db.game_item_template().entry().find(src.entry) { if tmpl.max_stack > 1 { let moved = merge_amount(src.stack_count, dst.stack_count, tmpl.max_stack); diff --git a/module/src/items/mod.rs b/module/src/items/mod.rs index 8a5c800f4..f7ec6c818 100644 --- a/module/src/items/mod.rs +++ b/module/src/items/mod.rs @@ -22,6 +22,7 @@ mod economy; mod inventory; mod ops; +mod properties; mod rules; mod tables; @@ -42,5 +43,6 @@ pub(crate) fn refused(refusal: ItemRefusal) -> String { pub(crate) use economy::*; // the apply_* cores are pub(crate) (no pub items to re-export) pub(crate) use inventory::*; // ditto pub(crate) use ops::*; // ditto +pub use properties::*; pub use rules::*; pub use tables::*; diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 107529002..4c8da7b19 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -9,7 +9,7 @@ use spacetimedb::{Identity, ReducerContext, Table}; use lyracore_shared::constants::starter_item; use lyracore_shared::item::ItemRefusal; -use super::refuse; +use super::{enchant_stat, property_stat, refuse, select_property}; use crate::game_character; // the durable char holds `class` (the live WorldEntity does not) use crate::game_corpse_loot; // the loot.rs accessor trait — re-exported at crate root (`pub use loot::*`) use crate::game_gameobject; @@ -19,8 +19,8 @@ use crate::game_world_entity; // gameobject (chest) loot source — apply_take_l use super::inventory::{first_free_backpack_slot, first_free_bag_slot, is_carried_slot}; use super::rules::{ - binds_on_grant, enchant_stat, equip_slot, meets_required_level, merge_amount, - resolve_equip_slot, template_stat, EquipStat, + binds_on_grant, equip_slot, meets_required_level, merge_amount, resolve_equip_slot, + template_stat, EquipStat, }; use super::tables::{ game_item_instance, game_item_template, item_guid_for, item_in_slot, item_is_broken, @@ -53,6 +53,9 @@ pub(crate) fn grant_starter_item(ctx: &ReducerContext, owner_guid: u64, owner_id let Some(tmpl) = ctx.db.game_item_template().entry().find(entry) else { return; }; + let Ok(random_property_id) = select_property(ctx, &tmpl) else { + return; + }; instances.insert(ItemInstance { guid: item_guid_for(owner_guid, slot), entry: tmpl.entry, @@ -66,6 +69,7 @@ pub(crate) fn grant_starter_item(ctx: &ReducerContext, owner_guid: u64, owner_id // BoP (bonding::BIND_ON_PICKUP) binds the instant it's granted — the starter kit is a // grant source like any other. soulbound: binds_on_grant(tmpl.bonding), + random_property_id, }); }; // Every character starts with a Hearthstone in the backpack (use it to recall to the bound home). @@ -155,6 +159,16 @@ pub(crate) fn grant_item( player_guid: u64, item_entry: u32, count: u32, +) -> Result<(), String> { + grant_item_property(ctx, player_guid, item_entry, count, None) +} + +pub(crate) fn grant_item_property( + ctx: &ReducerContext, + player_guid: u64, + item_entry: u32, + count: u32, + property: Option, ) -> Result<(), String> { let player = crate::helpers::live_entity(ctx, player_guid) .map_err(|_| "player not in world".to_string())?; @@ -171,14 +185,13 @@ pub(crate) fn grant_item( &tmpl, count.max(1), false, + property, ) } -/// Add `count` units of `tmpl` to `player_guid`'s backpack the way vanilla auto-store does (parity): -/// TOP UP existing partial stacks of the same entry first (lowest slot first), then spill the remainder -/// into free backpack slots (each new stack ≤ `max_stack`). `Err("inventory full")` if it can't all fit — -/// the caller's reducer rolls back (so a buy that overflows un-charges). Shared by grant / buy / loot so a -/// second food or arrow tops up the first stack instead of fragmenting, and a multi-stack grant spills. +/// Add items to matching carried stacks, then free backpack or bag slots. +/// Capacity and every Random Property are resolved before changing any item, so callers may +/// handle an inventory-full Refusal without keeping a partial grant. pub(crate) fn store_item( ctx: &ReducerContext, player_guid: u64, @@ -186,39 +199,58 @@ pub(crate) fn store_item( tmpl: &ItemTemplate, mut count: u32, force_soulbound: bool, + preselected_property: Option, ) -> Result<(), String> { + if count == 0 { + return Ok(()); + } let instances = ctx.db.game_item_instance(); let max_stack = tmpl.max_stack.max(1); - // 1. Merge into existing partial stacks (only stackables have headroom; lowest slot first). - if max_stack > 1 { - let mut partials: Vec = instances - .by_owner_guid() - .filter(&player_guid) - // Carried stacks only — loot never merges into a banked stack. - .filter(|i| { - is_carried_slot(i.slot) && i.entry == tmpl.entry && i.stack_count < max_stack - }) - .collect(); - partials.sort_by_key(|i| i.slot); - for mut inst in partials { - if count == 0 { - break; - } - let add = merge_amount(count, inst.stack_count, max_stack); - inst.stack_count += add; - count -= add; - instances.guid().update(inst); + let random_property_id = preselected_property + .map(Ok) + .unwrap_or_else(|| select_property(ctx, tmpl))?; + let mut partials: Vec = instances + .by_owner_guid() + .filter(&player_guid) + .filter(|i| { + is_carried_slot(i.slot) + && i.entry == tmpl.entry + && i.random_property_id == random_property_id + && i.stack_count < max_stack + }) + .collect(); + partials.sort_by_key(|i| i.slot); + let remaining = partials.iter().fold(count, |left, item| { + left.saturating_sub(max_stack - item.stack_count) + }); + let new_stacks = remaining.div_ceil(max_stack); + if new_stacks > super::inventory::count_free_inventory_slots(ctx, player_guid) { + return Err(lyracore_shared::mail::INVENTORY_FULL.to_owned()); + } + let mut properties = Vec::with_capacity(new_stacks as usize); + for index in 0..new_stacks { + properties.push(if index == 0 || preselected_property.is_some() { + random_property_id + } else { + select_property(ctx, tmpl)? + }); + } + for mut item in partials { + let add = merge_amount(count, item.stack_count, max_stack); + if add == 0 { + break; } + item.stack_count += add; + item.soulbound |= force_soulbound || binds_on_grant(tmpl.bonding); + count -= add; + instances.guid().update(item); } - // 2. Spill the remainder into free slots: backpack first (23..=38), then equipped bags (in - // bag-equip order 19..22). Both searches land in the same flat `game_item_instance` model; - // the gateway routes items in the bag-content range (120..=191) via the container object. - while count > 0 { - let slot = free_slot(ctx, player_guid)?; + for random_property_id in properties { + // The capacity check counted these same slots, and reducers cannot interleave. + let slot = free_slot(ctx, player_guid).expect("preflight reserved a free item slot"); let take = count.min(max_stack); - let new_guid = next_item_guid(ctx, player_guid, slot); instances.insert(ItemInstance { - guid: new_guid, + guid: next_item_guid(ctx, player_guid, slot), entry: tmpl.entry, owner_identity, owner_guid: player_guid, @@ -226,13 +258,9 @@ pub(crate) fn store_item( stack_count: take, durability: tmpl.max_durability, created_at: ctx.timestamp, - enchant_id: 0, // freshly stored stack — unenchanted (enchants apply to equipped non-stackables) - // BoP binds the instant it's stored — covers every `store_item` caller (grant / buy / loot / - // buyback) uniformly, matching vanilla: a Bind-on-Pickup item binds regardless of source. - // `force_soulbound` preserves an already-bound instance's state across buyback: a BoE - // item bound before being sold must come back bound, not re-derived from the template - // alone. + enchant_id: 0, soulbound: force_soulbound || binds_on_grant(tmpl.bonding), + random_property_id, }); count -= take; } @@ -264,6 +292,7 @@ pub(crate) struct ItemSnapshot { pub durability: u32, pub enchant_id: u32, pub soulbound: bool, + pub random_property_id: u32, } impl ItemSnapshot { @@ -280,6 +309,7 @@ impl From<&ItemInstance> for ItemSnapshot { durability: item.durability, enchant_id: item.enchant_id, soulbound: item.soulbound, + random_property_id: item.random_property_id, } } } @@ -313,6 +343,7 @@ pub(crate) fn store_instance_state( // The recorded bind state, ORed with the template's grant-time rule — `store_item`'s // expression exactly, so an arriving item cannot end up less bound than a granted one. soulbound: snapshot.soulbound || binds_on_grant(tmpl.bonding), + random_property_id: snapshot.random_property_id, }); Ok(()) } @@ -582,9 +613,13 @@ pub(crate) fn equipped_stat_bonus(ctx: &ReducerContext, owner_guid: u64, which: // the 13). `enchant_stat` is 0 for an unenchanted item (enchant_id 0) → byte-identical readout // for every existing/unenchanted piece (baseline-safe). A broken item already returned above, so // a broken-but-enchanted piece grants neither — the enchant rides the item's working state. - Some(template_stat(&tmpl, which) + enchant_stat(i.enchant_id, which)) + Some( + template_stat(&tmpl, which) + .saturating_add(enchant_stat(ctx, i.enchant_id, which.kind())) + .saturating_add(property_stat(ctx, i.random_property_id, which.kind())), + ) }) - .sum() + .fold(0i32, i32::saturating_add) } /// The pure per-item durability-loss formula `apply_death_durability_loss` applies to each equipped @@ -746,6 +781,7 @@ pub(crate) fn apply_take_loot( &tmpl, row.count.max(1), false, + Some(row.random_property_id), )?; // Consume the loot: remove the row so a second take can't dupe it. let (item_entry, count) = (row.item_entry, row.count.max(1)); @@ -762,6 +798,7 @@ pub(crate) fn apply_take_loot( corpse_guid, item_entry, count, + row.random_property_id, ); } else { crate::loot::clone_quest_loot_for_group( @@ -770,6 +807,7 @@ pub(crate) fn apply_take_loot( corpse_guid, item_entry, count, + row.random_property_id, ); } } diff --git a/module/src/items/properties.rs b/module/src/items/properties.rs new file mode 100644 index 000000000..2935dd321 --- /dev/null +++ b/module/src/items/properties.rs @@ -0,0 +1,224 @@ +//! Random Property selection and the shared enchantment contribution to equipped stats. + +use super::{game_item_template, EquipStat, ItemTemplate}; +use lyracore_shared::item_property as kind; +use spacetimedb::{table, ReducerContext, Table}; + +#[table(accessor = game_item_random_property, public)] +pub struct ItemRandomProperty { + #[primary_key] + pub property_id: u32, + pub enchant_id_1: u32, + pub enchant_id_2: u32, + pub enchant_id_3: u32, + pub suffix: String, +} + +#[table(accessor = game_item_enchantment, public, index(accessor = by_enchant, btree(columns = [enchant_id])))] +pub struct ItemEnchantment { + #[primary_key] + pub id: u64, + pub enchant_id: u32, + pub effect_index: u8, + pub kind: u8, + pub amount: i32, + pub spell_id: u32, + pub school_mask: u32, +} + +#[table(accessor = game_item_property_weight, public, index(accessor = by_pool, btree(columns = [pool_id])))] +pub struct ItemPropertyWeight { + #[primary_key] + pub id: u64, + pub pool_id: u32, + pub property_id: u32, + pub weight: u32, +} + +/// Stable property-ID order, normalized to the total integer weight. Band endpoints round up. +/// Input is in 0..10000. Empty pools, zero IDs, duplicate IDs and zero weights are invalid. +fn property_pick(mut members: Vec<(u32, u32)>, roll: u32) -> Option { + if roll >= 10_000 || members.is_empty() { + return None; + } + members.sort_unstable_by_key(|member| member.0); + if members.iter().any(|&(id, weight)| id == 0 || weight == 0) + || members.windows(2).any(|pair| pair[0].0 == pair[1].0) + { + return None; + } + let total: u128 = members.iter().map(|member| u128::from(member.1)).sum(); + let point = u128::from(roll) * total; + let mut cumulative = 0u128; + for (id, weight) in members { + cumulative += u128::from(weight); + if point < cumulative * 10_000 { + return Some(id); + } + } + None +} + +pub(crate) fn select_property(ctx: &ReducerContext, tmpl: &ItemTemplate) -> Result { + if tmpl.random_property == 0 { + return Ok(0); + } + let members: Vec<_> = ctx + .db + .game_item_property_weight() + .by_pool() + .filter(tmpl.random_property) + .map(|row| (row.property_id, row.weight)) + .collect(); + if members.iter().any(|(id, _)| { + ctx.db + .game_item_random_property() + .property_id() + .find(id) + .is_none() + }) { + return Err(format!( + "property pool {} has a missing Random Property", + tmpl.random_property + )); + } + property_pick(members, ctx.random::() % 10_000).ok_or_else(|| { + format!( + "invalid property pool {} for item {}", + tmpl.random_property, tmpl.entry + ) + }) +} + +pub(crate) fn select_loot_property(ctx: &ReducerContext, entry: u32) -> Result { + let tmpl = ctx + .db + .game_item_template() + .entry() + .find(entry) + .ok_or_else(|| format!("no template for loot item {entry}"))?; + select_property(ctx, &tmpl) +} + +pub(crate) fn enchant_stat(ctx: &ReducerContext, enchant_id: u32, stat_kind: u8) -> i32 { + if enchant_id == 0 { + return 0; + } + ctx.db + .game_item_enchantment() + .by_enchant() + .filter(enchant_id) + .filter(|row| row.kind != kind::UNKNOWN && row.kind == stat_kind) + .fold(0i32, |sum, row| sum.saturating_add(row.amount)) +} + +pub(crate) fn property_stat(ctx: &ReducerContext, property_id: u32, stat_kind: u8) -> i32 { + if property_id == 0 { + return 0; + } + let Some(property) = ctx + .db + .game_item_random_property() + .property_id() + .find(property_id) + else { + return 0; + }; + [ + property.enchant_id_1, + property.enchant_id_2, + property.enchant_id_3, + ] + .into_iter() + .map(|id| enchant_stat(ctx, id, stat_kind)) + .fold(0i32, i32::saturating_add) +} + +pub(crate) fn is_known_enchant(ctx: &ReducerContext, enchant_id: u32) -> bool { + enchant_id != 0 + && ctx + .db + .game_item_enchantment() + .by_enchant() + .filter(enchant_id) + .any(|row| row.kind != kind::UNKNOWN) +} + +impl EquipStat { + pub(crate) fn kind(self) -> u8 { + match self { + Self::Strength => kind::STRENGTH, + Self::Agility => kind::AGILITY, + Self::Stamina => kind::STAMINA, + Self::Intellect => kind::INTELLECT, + Self::Spirit => kind::SPIRIT, + Self::Crit => kind::CRIT, + Self::Hit => kind::HIT, + Self::Armor => kind::ARMOR, + } + } +} + +pub(crate) fn seed_compatibility_enchantments(ctx: &ReducerContext) { + for (enchant_id, kind, amount) in kind::COMPATIBILITY_ENCHANTMENTS { + let id = u64::from(enchant_id) << 8; + if ctx.db.game_item_enchantment().id().find(id).is_none() { + ctx.db.game_item_enchantment().insert(ItemEnchantment { + id, + enchant_id, + effect_index: 0, + kind, + amount, + spell_id: 0, + school_mask: 0, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::property_pick; + + #[test] + fn every_input_follows_proportional_bands_in_property_order() { + for roll in 0..10_000 { + assert_eq!( + property_pick(vec![(30, 30), (10, 10), (20, 20)], roll), + Some(if roll < 1667 { + 10 + } else if roll < 5000 { + 20 + } else { + 30 + }) + ); + } + } + + #[test] + fn underweight_and_overweight_pools_cover_every_input() { + for roll in 0..10_000 { + assert_eq!( + property_pick(vec![(10, 4976), (20, 4976)], roll), + Some(if roll < 5000 { 10 } else { 20 }) + ); + assert_eq!( + property_pick(vec![(10, 4367), (20, 8734)], roll), + Some(if roll < 3334 { 10 } else { 20 }) + ); + } + } + + #[test] + fn invalid_pool_or_input_has_no_selection() { + for members in [vec![], vec![(0, 1)], vec![(1, 0)], vec![(1, 1), (1, 2)]] { + assert_eq!(property_pick(members, 0), None); + } + assert_eq!(property_pick(vec![(1, 1)], 10_000), None); + assert_eq!( + property_pick(vec![(1, u32::MAX), (2, u32::MAX)], 9999), + Some(2) + ); + } +} diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index 43568806e..6afc32204 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -303,41 +303,6 @@ pub(crate) fn template_stat(tmpl: &ItemTemplate, which: EquipStat) -> i32 { } } -/// ENCHANTING (completing the 13) — the per-instance enchant overlay table. `(enchant_id, EquipStat, -/// amount)`: an item carrying `enchant_id` adds `amount` to the named `EquipStat` ON TOP of its template -/// stat. The same single-meaning model as `template_stat` (typed col → amount), so an enchant folds through -/// the EXACT effective-* pipeline (combat swing/dodge/armor/crit/hit + the spell module's Stamina/Intellect → max -/// HP/mana). A handful of low-rank enchants is enough for the alpha (the real `SpellItemEnchantment.dbc` -/// import is DEFERRED). 0 (the column default) is the "no enchant" sentinel — never a row here, so an -/// unenchanted item contributes 0 (baseline-safe). [reference] -const ENCHANTS: &[(u32, EquipStat, i32)] = &[ - // Two low-rank exemplar enchants spanning a server-VERIFIABLE stat each: a +Strength weapon enchant - // (moves the melee swing readout) and a +Stamina chest enchant (moves max-HP via recompute_vitals). - (7745, EquipStat::Strength, 3), // "Minor Strength"-style weapon enchant: a flat +3 STR (verify via debug_compute_swing) - (7748, EquipStat::Stamina, 3), // "Minor Stamina"-style chest enchant: a flat +3 STA (verify via recompute_vitals max-HP) -]; - -/// This enchant's contribution to one `EquipStat` (0 if it doesn't touch that stat, or the id is 0/unknown). -/// Pure (no ctx) so the overlay arithmetic is unit-testable on plain values. Sums matching rows (an enchant -/// id appears at most once per stat today, but summing keeps a future multi-stat enchant correct). -/// `equipped_stat_bonus` adds this alongside `template_stat` for each equipped instance. [reference] -pub(crate) fn enchant_stat(enchant_id: u32, which: EquipStat) -> i32 { - if enchant_id == 0 { - return 0; // the "no enchant" sentinel — never matches a row, short-circuit - } - ENCHANTS - .iter() - .filter(|(id, stat, _)| *id == enchant_id && *stat == which) - .map(|(_, _, amount)| *amount) - .sum() -} - -/// Whether `enchant_id` is a known, applyable enchant (in the ENCHANTS table). Gates `enchant_item` so a -/// player can't stamp an arbitrary id onto an instance. Pure — unit-tested. 0 is "none" → not applyable here. -pub(crate) fn is_known_enchant(enchant_id: u32) -> bool { - enchant_id != 0 && ENCHANTS.iter().any(|(id, _, _)| *id == enchant_id) -} - /// The equipment slot an item in `from_slot` should EQUIP into, given its `inventory_type` and the /// player's current equipped set — the auto-resolve vanilla performs on a right-click-equip. Pure over /// the (resolved-slot, occupied-set) inputs so it's unit-testable. Returns `None` if the item isn't @@ -664,6 +629,7 @@ pub(crate) mod tests { food_type: 0, allowed_class: crate::items::tables::ALL_PLAYABLE_CLASS_MASK, allowed_race: crate::items::tables::ALL_PLAYABLE_RACE_MASK, + random_property: 0, } } @@ -717,60 +683,6 @@ pub(crate) mod tests { assert_eq!(sum(&plain, EquipStat::Hit), 0); } - /// Re-scoped (was `enchant_overlay_adds_its_stat_on_top_of_the_template`): the name implied this pins - /// `equipped_stat_bonus`'s actual fold, but that fold runs over live equipped `ItemInstance` rows (a - /// ctx fn, not exercised here) — this test only hand-adds `template_stat + enchant_stat`, the two pure - /// pieces, to show the composition is sensible. What IS real and unit-tested here: `enchant_stat` - /// (7745 = +3 STR, 7748 = +3 STA, touching no other stat; id 0 and an unknown id add 0 to every stat) - /// and `is_known_enchant`'s validity gate. - #[test] - fn enchant_stat_adds_its_amount_on_top_of_a_hand_summed_template_stat() { - // A base weapon with +5 STR from its template; the +3 STR enchant (7745) lifts effective STR to +8. - let mut weapon = blank_template(3001); - weapon.stat_strength = 5; - let base = template_stat(&weapon, EquipStat::Strength); - assert_eq!(base, 5); - assert_eq!( - enchant_stat(7745, EquipStat::Strength), - 3, - "the +STR weapon enchant adds 3" - ); - assert_eq!( - base + enchant_stat(7745, EquipStat::Strength), - 8, - "effective STR rose by the enchant" - ); - // The +STR enchant touches ONLY Strength — it adds nothing to other stats. - assert_eq!(enchant_stat(7745, EquipStat::Stamina), 0); - assert_eq!(enchant_stat(7745, EquipStat::Agility), 0); - // The +STA chest enchant (7748) moves Stamina (→ max-HP via recompute_vitals), not Strength. - assert_eq!(enchant_stat(7748, EquipStat::Stamina), 3); - assert_eq!(enchant_stat(7748, EquipStat::Strength), 0); - // The "no enchant" sentinel (0) and any unknown id add 0 to EVERY stat → an unenchanted/legacy item - // reads byte-identical (baseline-safe). And only known ids are applyable. - for which in [ - EquipStat::Strength, - EquipStat::Stamina, - EquipStat::Agility, - EquipStat::Armor, - ] { - assert_eq!(enchant_stat(0, which), 0, "enchant_id 0 = no overlay"); - assert_eq!( - enchant_stat(99999, which), - 0, - "an unknown enchant adds nothing" - ); - } - assert!( - is_known_enchant(7745) && is_known_enchant(7748), - "the seeded enchants are applyable" - ); - assert!( - !is_known_enchant(0) && !is_known_enchant(99999), - "0 and unknown ids are not applyable" - ); - } - #[test] fn resolve_equip_slot_picks_first_free_of_a_pair() { use equip_slot as e; diff --git a/module/src/items/tables.rs b/module/src/items/tables.rs index 147381914..3d4a31bb2 100644 --- a/module/src/items/tables.rs +++ b/module/src/items/tables.rs @@ -197,6 +197,8 @@ pub struct ItemTemplate { pub allowed_class: u32, #[default(ALL_PLAYABLE_RACE_MASK)] pub allowed_race: u32, + #[default(0)] + pub random_property: u32, } /// A per-player owned item. Public but RLS-restricted to the owner (like `game_character`), so a @@ -214,13 +216,7 @@ pub struct ItemInstance { pub stack_count: u32, pub durability: u32, pub created_at: Timestamp, - /// ENCHANTING (completing the 13): the per-instance enchant applied to THIS item (0 = none). A small - /// const table (`rules::ENCHANTS`) maps the id → (EquipStat, amount); `equipped_stat_bonus` adds that - /// amount alongside the template stat for an equipped enchanted piece, so an enchant is server-real - /// everywhere the effective-* pipeline reads (combat swing/dodge/armor/crit/hit + max-HP/mana). The - /// 5875 client caches item stats BY ENTRY, so the per-instance enchant glow/green-text is NOT - /// client-displayed — DEFERRED (launcher/addon territory). END-appended + `#[default(0)]` → additive - /// auto-migration (every existing row reads 0 = unenchanted, baseline-safe). [entity] + /// Applied enchantment, resolved through the item enchantment catalogue. Zero means none. #[default(0)] pub enchant_id: u32, /// Item binding state: true once this SPECIFIC instance has bound to its owner and cannot be @@ -233,6 +229,8 @@ pub struct ItemInstance { /// unbound, baseline-safe). [entity] #[default(false)] pub soulbound: bool, + #[default(0)] + pub random_property_id: u32, } // Character-owned sweeps: items are deleted on character delete, re-owned (identity re-stamp) on a @@ -353,6 +351,8 @@ pub struct BuybackEntry { // trip. END-appended + `#[default(false)]` → auto-migrates. #[default(false)] pub soulbound: bool, + #[default(0)] + pub random_property_id: u32, } // Character-owned sweep: the buyback ring is deleted (not re-owned) on character delete — there's no diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index c25457cf9..0c72a89dd 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -121,6 +121,8 @@ pub struct CorpseLoot { /// a successful grant, or flipping `reserved_for` to the winner on an inventory-full fallback). #[default(false)] pub withheld: bool, + #[default(0)] + pub random_property_id: u32, } /// A creature's loot-table entry: when an `creature_entry` creature dies, its rows roll for drops. @@ -484,6 +486,13 @@ pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) { /// [`roll_pickpocket_loot`] used to each carry a copy of (the dedup). fn insert_corpse_rows(ctx: &ReducerContext, corpse_guid: u64, winners: Vec<(u32, u32, bool)>) { for (slot, (item_entry, count, quest_only)) in winners.into_iter().enumerate() { + let random_property_id = match crate::items::select_loot_property(ctx, item_entry) { + Ok(id) => id, + Err(reason) => { + spacetimedb::log::error!("loot item {item_entry}: {reason}"); + continue; + } + }; ctx.db.game_corpse_loot().insert(CorpseLoot { id: 0, corpse_guid, @@ -497,6 +506,7 @@ fn insert_corpse_rows(ctx: &ReducerContext, corpse_guid: u64, winners: Vec<(u32, designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id, }); } } @@ -648,6 +658,7 @@ pub(crate) fn clone_quest_loot_for_group( corpse_guid: u64, item_entry: u32, count: u32, + random_property_id: u32, ) { let Some(m) = crate::group::group_of(ctx, taker_guid) else { return; @@ -689,6 +700,7 @@ pub(crate) fn clone_quest_loot_for_group( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id, }); } } @@ -702,6 +714,7 @@ pub(crate) fn clone_quest_loot_for_eligible( corpse_guid: u64, item_entry: u32, count: u32, + random_property_id: u32, ) { let others: Vec<(u64, bool)> = corpse_eligible_recipients(ctx, corpse_guid) .into_iter() @@ -737,6 +750,7 @@ pub(crate) fn clone_quest_loot_for_eligible( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id, }); } } @@ -1189,6 +1203,7 @@ mod tests { designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: 0, }; assert!(corpse_row.quest_only); assert_eq!(corpse_row.reserved_for, 42); @@ -1203,6 +1218,7 @@ mod tests { designated_looter_guid: 7, master_only: true, withheld: true, + random_property_id: 0, }; assert!( !shared_row.quest_only, diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 18b30c0f2..cd4623528 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -97,6 +97,8 @@ pub struct LootRoll { pub item_entry: u32, pub deadline_micros: i64, pub resolved: bool, + #[default(0)] + pub random_property_id: u32, } /// One eligible member's vote on a [`LootRoll`] — snapshotted (one row per eligible guid) the moment @@ -308,7 +310,15 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro ctx.db.game_corpse_loot().id().update(row); } GroupLootDecision::Roll => { - start_roll(ctx, corpse_guid, row.slot, row.item_entry, &recipients, now); + start_roll( + ctx, + corpse_guid, + row.slot, + row.item_entry, + &recipients, + now, + row.random_property_id, + ); row.withheld = true; ctx.db.game_corpse_loot().id().update(row); } @@ -338,6 +348,7 @@ fn insert_roll_rows( item_entry: u32, recipients: &[u64], deadline_micros: i64, + random_property_id: u32, ) -> u64 { let rolls = ctx.db.game_loot_roll(); if let Some(existing) = rolls @@ -354,6 +365,7 @@ fn insert_roll_rows( item_entry, deadline_micros, resolved: false, + random_property_id, }); let votes = ctx.db.game_loot_roll_vote(); for &guid in recipients { @@ -384,6 +396,7 @@ fn start_roll( item_entry: u32, recipients: &[u64], now_micros: i64, + random_property_id: u32, ) { insert_roll_rows( ctx, @@ -392,12 +405,14 @@ fn start_roll( item_entry, recipients, now_micros + ROLL_WINDOW_MICROS, + random_property_id, ); let payload = lyracore_shared::loot_roll::encode_start( corpse_guid, slot, item_entry, (ROLL_WINDOW_MICROS / 1000) as u32, + random_property_id, ); for &guid in recipients { crate::group::push_event(ctx, guid, roll_event_kind::ROLL_START, 0, payload.clone()); @@ -456,6 +471,7 @@ pub(crate) fn cast_vote_on( my_rolled, vote, false, + roll.random_property_id, ); for &guid in &recipients { crate::group::push_event( @@ -524,7 +540,13 @@ pub(crate) fn settle_roll_grant( ); return; // not a live roll's row on THIS database — see the doc above } - match crate::items::grant_item(ctx, winner_guid, row.item_entry, row.count.max(1)) { + match crate::items::grant_item_property( + ctx, + winner_guid, + row.item_entry, + row.count.max(1), + Some(row.random_property_id), + ) { Ok(()) => { loot.id().delete(row.id); super::refresh_lootable(ctx, corpse_guid); @@ -597,6 +619,7 @@ fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], r roll.item_entry, winning_roll, winning_vote, + roll.random_property_id, ); for &guid in recipients { crate::group::push_event( @@ -708,6 +731,7 @@ pub(crate) fn force_resolve_rolls_for_disband( roll.item_entry, 0, lyracore_shared::loot_roll::vote_kind::NEED, + roll.random_property_id, ); crate::group::push_event( ctx, @@ -749,6 +773,7 @@ pub fn realm_loot_op( vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; use lyracore_shared::loot_roll::loot_op; @@ -764,6 +789,7 @@ pub fn realm_loot_op( item_entry, &recipients, deadline_micros, + random_property_id, ); Ok(()) } @@ -839,8 +865,14 @@ pub(crate) fn apply_master_give( &format!("master-loot recipient {target_guid} is not in the world"), ) })?; - crate::items::grant_item(ctx, target_guid, row.item_entry, row.count.max(1)) - .map_err(master_delivery_error)?; + crate::items::grant_item_property( + ctx, + target_guid, + row.item_entry, + row.count.max(1), + Some(row.random_property_id), + ) + .map_err(master_delivery_error)?; loot.id().delete(row.id); super::refresh_lootable(ctx, corpse_guid); Ok(()) @@ -1025,7 +1057,7 @@ mod tests { fn realm_loot_op_dispatches_start_and_vote_to_their_own_cores() { let body = code_of(include_str!("rolls.rs"), "pub fn realm_loot_op("); for (op, core) in [ - ("loot_op::START =>", "{ insert_roll_rows( ctx, corpse_guid, slot, item_entry, &recipients, deadline_micros, ); Ok(()) }"), + ("loot_op::START =>", "{ insert_roll_rows( ctx, corpse_guid, slot, item_entry, &recipients, deadline_micros, random_property_id, ); Ok(()) }"), ("loot_op::VOTE =>", "cast_vote_on(ctx, corpse_guid, slot, actor_guid, vote)"), ] { let arm = body @@ -1055,8 +1087,8 @@ mod tests { .find("if !row.withheld {") .expect("settle_roll_grant no longer gates on `row.withheld` — see this fn's own doc"); let grant_at = body - .find("crate::items::grant_item(") - .expect("settle_roll_grant no longer calls grant_item at all"); + .find("crate::items::grant_item_property(") + .expect("settle_roll_grant no longer calls grant_item_property"); assert!( withheld_at < grant_at, "the `withheld` guard must run BEFORE `grant_item` — a guard added AFTER the grant \ diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index fda5f7a58..3bd86d5d1 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -409,6 +409,7 @@ pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: 0, }); let tagless_xp = entity_xp(ctx, LOOT_TAG_FIXTURE_CHARACTER_B)?; if !crate::combat::kill_creature(ctx, tagless, Some(LOOT_TAG_FIXTURE_CHARACTER_B)) { @@ -625,6 +626,7 @@ fn verify_corpse_loot_gates( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: 0, }); record_corpse_eligibility( ctx, @@ -676,6 +678,7 @@ fn verify_corpse_loot_gates( designated_looter_guid: 0, master_only: false, withheld: false, + random_property_id: 0, }); record_corpse_eligibility(ctx, solo_corpse, &[LOOT_TAG_FIXTURE_CHARACTER_A]); crate::loot::open_creature_corpse(ctx, LOOT_TAG_FIXTURE_CHARACTER_A, solo_corpse)?; diff --git a/module/src/mail.rs b/module/src/mail.rs index 55bb78538..ea9a72592 100644 --- a/module/src/mail.rs +++ b/module/src/mail.rs @@ -25,6 +25,8 @@ pub struct Mail { pub cod: u32, pub was_read: bool, pub created_at: Timestamp, + #[default(0)] + pub random_property_id: u32, } crate::character_owned!(delete, fn sweep_delete_game_mail(ctx, character_guid) { let mails = ctx.db.game_mail(); @@ -45,6 +47,7 @@ impl Mail { durability: self.item_durability, enchant_id: self.item_enchant_id, soulbound: self.item_soulbound, + random_property_id: self.random_property_id, } } } @@ -130,6 +133,7 @@ pub(crate) fn insert_mail( item_durability: item.durability, item_enchant_id: item.enchant_id, item_soulbound: item.soulbound, + random_property_id: item.random_property_id, money, cod, was_read: false, @@ -268,6 +272,7 @@ pub(crate) fn clear_mail_item(ctx: &ReducerContext, mail_id: u64) { item_durability: 0, item_enchant_id: 0, item_soulbound: false, + random_property_id: 0, ..row }); } diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index c9ef16954..5921b2f78 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -40,6 +40,8 @@ pub struct MailEscrow { pub item_soulbound: bool, #[default(0u32)] pub cod: u32, + #[default(0)] + pub random_property_id: u32, } impl MailEscrow { @@ -51,6 +53,7 @@ impl MailEscrow { durability: self.item_durability, enchant_id: self.item_enchant_id, soulbound: self.item_soulbound, + random_property_id: self.random_property_id, } } } @@ -457,6 +460,7 @@ pub(crate) fn apply_fence( item_durability: item.durability, item_enchant_id: item.enchant_id, item_soulbound: item.soulbound, + random_property_id: item.random_property_id, cod: letter.cod, }); sink.arm_reaper(); @@ -565,6 +569,7 @@ pub(crate) fn apply_take_fence( item_durability: 0, item_enchant_id: 0, item_soulbound: false, + random_property_id: 0, cod: 0, }); sink.arm_reaper(); @@ -634,6 +639,7 @@ pub(crate) fn apply_take_item_fence( item_durability: item.durability, item_enchant_id: item.enchant_id, item_soulbound: item.soulbound, + random_property_id: item.random_property_id, cod: 0, }); sink.arm_reaper(); @@ -820,6 +826,7 @@ pub fn realm_mail_commit( item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, cod: u32, cod_mail_id: u64, ) -> Result<(), String> { @@ -842,6 +849,7 @@ pub fn realm_mail_commit( durability: item_durability, enchant_id: item_enchant_id, soulbound: item_soulbound, + random_property_id, }, cod_mail_id, ) @@ -903,6 +911,7 @@ pub fn realm_mail_item_payout( item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, ) -> Result<(), String> { require_operator(ctx)?; apply_item_payout( @@ -916,6 +925,7 @@ pub fn realm_mail_item_payout( durability: item_durability, enchant_id: item_enchant_id, soulbound: item_soulbound, + random_property_id, }, ) } @@ -1118,7 +1128,7 @@ mod tests { sender_guid, &Letter { recipient_guid, subject, body, money, postage: 0, cod, \ }, &crate::items::ItemSnapshot { entry: item_entry, stack_count: \ item_stack_count, durability: item_durability, enchant_id: item_enchant_id, \ - soulbound: item_soulbound, }, cod_mail_id, ) }", + soulbound: item_soulbound, random_property_id, }, cod_mail_id, ) }", ), ( "pub fn realm_mail_take_money_fence(", @@ -1140,7 +1150,7 @@ mod tests { "{ require_operator(ctx)?; apply_item_payout( &mut CtxDb { ctx }, escrow_id, \ payee_guid, mail_id, &crate::items::ItemSnapshot { entry: item_entry, \ stack_count: item_stack_count, durability: item_durability, enchant_id: \ - item_enchant_id, soulbound: item_soulbound, }, ) }", + item_enchant_id, soulbound: item_soulbound, random_property_id, }, ) }", ), ( "pub fn realm_mail_confirm_delivery(", diff --git a/module/src/mail_escrow/harness.rs b/module/src/mail_escrow/harness.rs index de0fb2c3d..b47bf881a 100644 --- a/module/src/mail_escrow/harness.rs +++ b/module/src/mail_escrow/harness.rs @@ -68,6 +68,7 @@ impl EscrowLedger for FakeLedger { item_durability: e.item.durability, item_enchant_id: e.item.enchant_id, item_soulbound: e.item.soulbound, + random_property_id: e.item.random_property_id, cod: e.cod, }) } @@ -429,6 +430,7 @@ fn sword() -> ItemSnapshot { durability: 42, enchant_id: 7, soulbound: false, + random_property_id: 117, } } @@ -1146,6 +1148,7 @@ fn a_soulbound_item_is_refused_at_send_and_stays_in_the_senders_bags() { ITEM_GUID, ItemSnapshot { soulbound: true, + random_property_id: 117, ..sword() }, ); diff --git a/module/src/package_import/items.rs b/module/src/package_import/items.rs index 4ba56926f..fa8b6acfc 100644 --- a/module/src/package_import/items.rs +++ b/module/src/package_import/items.rs @@ -162,6 +162,7 @@ fn blank_item(entry: u32) -> ItemTemplate { food_type: 0, allowed_class: 0, allowed_race: 0, + random_property: 0, } } diff --git a/module/src/professions.rs b/module/src/professions.rs index 734b37c20..206001b3f 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -381,7 +381,7 @@ pub(crate) fn apply_enchant_item( return Err("dead players cannot enchant".to_string()); } // The enchant id must be a known, applyable enchant — a client can't stamp an arbitrary id. - if !crate::items::is_known_enchant(enchant_id) { + if !crate::items::is_known_enchant(ctx, enchant_id) { return Err(format!("unknown enchant {enchant_id}")); } let mut inst = crate::items::item_in_slot(ctx, guid, target_slot) diff --git a/module/src/seed.rs b/module/src/seed.rs index cfb73b06f..d7f04dc46 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -58,6 +58,7 @@ pub fn init(ctx: &ReducerContext) { seed_production_core(ctx); seed_map0_demo_content(ctx); seed_spell_registry(ctx); + crate::items::seed_compatibility_enchantments(ctx); seed_scheduler_arming(ctx); } diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index ad37e2911..b11a64c76 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -562,6 +562,7 @@ pub(crate) fn base_item(entry: u32, name: &str) -> ItemTemplate { food_type: 0, allowed_class: ALL_PLAYABLE_CLASS_MASK, allowed_race: ALL_PLAYABLE_RACE_MASK, + random_property: 0, } } diff --git a/module/src/trade.rs b/module/src/trade.rs index 6459726c8..e7c562007 100644 --- a/module/src/trade.rs +++ b/module/src/trade.rs @@ -286,6 +286,7 @@ pub(crate) fn offer_slot_view( enchantment: inst.enchant_id, durability: inst.durability, max_durability: tmpl.max_durability, + random_property_id: inst.random_property_id, } } @@ -1052,6 +1053,7 @@ mod tests { created_at: Timestamp::UNIX_EPOCH, enchant_id: 2564, soulbound: false, + random_property_id: 117, }; assert_eq!( offer_slot_view(6, &inst, &tmpl), @@ -1063,6 +1065,7 @@ mod tests { enchantment: 2564, durability: 34, max_durability: 40, + random_property_id: 117, } ); } diff --git a/module/src/transfer/legacy_item_rows.rs b/module/src/transfer/legacy_item_rows.rs new file mode 100644 index 000000000..607e30ddd --- /dev/null +++ b/module/src/transfer/legacy_item_rows.rs @@ -0,0 +1,175 @@ +//! Preserve plain items in Escrow created before Random Property fields existed. + +use std::collections::BTreeSet; + +use spacetimedb::{Identity, SpacetimeType, Timestamp}; + +use super::TableRows; +use crate::items::{BuybackEntry, ItemInstance}; +use crate::mail::Mail; + +const FORMATS: &[(&str, &str)] = &[ + ("game_item_instance", "game_item_instance@random-property-1"), + ( + "game_character_buyback", + "game_character_buyback@random-property-1", + ), + ("game_mail", "game_mail@random-property-1"), +]; + +/// Older Modules require canonical payload names and refuse these tags before applying rows. +pub(super) fn mark_current(payload: &mut [TableRows]) { + for entry in payload { + if let Some((_, tagged)) = FORMATS.iter().find(|(table, _)| *table == entry.table) { + entry.table = (*tagged).to_owned(); + } + } +} + +/// Defaults migrate table rows, but do not rewrite the opaque bytes in existing Transfer Escrow. +/// Decode each untagged item table with its old shape; never guess its format from a decode error. +pub(super) fn prepare(payload: &[TableRows]) -> Result, String> { + let mut seen = BTreeSet::new(); + let mut prepared = Vec::with_capacity(payload.len()); + for entry in payload { + let mut entry = entry.clone(); + if let Some((table, _)) = FORMATS.iter().find(|(_, tagged)| *tagged == entry.table) { + entry.table = (*table).to_owned(); + } else { + entry.rows = match entry.table.as_str() { + "game_item_instance" => upgrade::(&entry.rows), + "game_character_buyback" => { + upgrade::(&entry.rows) + } + "game_mail" => upgrade::(&entry.rows), + _ => Ok(entry.rows), + } + .map_err(|error| format!("table {}: {error}", entry.table))?; + } + if !seen.insert(entry.table.clone()) { + return Err(format!("arriving payload repeats table {}", entry.table)); + } + prepared.push(entry); + } + Ok(prepared) +} + +fn upgrade(bytes: &[u8]) -> Result, String> +where + Old: for<'de> spacetimedb::sats::Deserialize<'de>, + Current: From + spacetimedb::sats::Serialize, +{ + if bytes.is_empty() { + return Ok(Vec::new()); + } + let mut remaining = bytes; + let rows: Vec = spacetimedb::sats::bsatn::from_reader(&mut remaining) + .map_err(|error| format!("cannot decode legacy item rows: {error}"))?; + if !remaining.is_empty() { + return Err("legacy item rows contain trailing bytes".to_owned()); + } + let rows: Vec = rows.into_iter().map(Current::from).collect(); + spacetimedb::sats::bsatn::to_vec(&rows) + .map_err(|error| format!("cannot encode upgraded item rows: {error}")) +} + +// These exact row shapes are the compatibility boundary for already persisted Escrow bytes. +#[derive(SpacetimeType)] +struct LegacyItemInstance { + guid: u64, + entry: u32, + owner_identity: Identity, + owner_guid: u64, + slot: u8, + stack_count: u32, + durability: u32, + created_at: Timestamp, + enchant_id: u32, + soulbound: bool, +} + +impl From for ItemInstance { + fn from(row: LegacyItemInstance) -> Self { + Self { + guid: row.guid, + entry: row.entry, + owner_identity: row.owner_identity, + owner_guid: row.owner_guid, + slot: row.slot, + stack_count: row.stack_count, + durability: row.durability, + created_at: row.created_at, + enchant_id: row.enchant_id, + soulbound: row.soulbound, + random_property_id: 0, + } + } +} + +#[derive(SpacetimeType)] +struct LegacyBuybackEntry { + id: u64, + player_guid: u64, + item_entry: u32, + stack_count: u32, + price: u32, + soulbound: bool, +} + +impl From for BuybackEntry { + fn from(row: LegacyBuybackEntry) -> Self { + Self { + id: row.id, + player_guid: row.player_guid, + item_entry: row.item_entry, + stack_count: row.stack_count, + price: row.price, + soulbound: row.soulbound, + random_property_id: 0, + } + } +} + +#[derive(SpacetimeType)] +struct LegacyMail { + id: u64, + recipient_guid: u64, + sender_guid: u64, + subject: String, + body: String, + item_entry: u32, + item_stack_count: u32, + item_durability: u32, + item_enchant_id: u32, + item_soulbound: bool, + money: u32, + cod: u32, + was_read: bool, + created_at: Timestamp, +} + +impl From for Mail { + fn from(row: LegacyMail) -> Self { + Self { + id: row.id, + recipient_guid: row.recipient_guid, + sender_guid: row.sender_guid, + subject: row.subject, + body: row.body, + item_entry: row.item_entry, + item_stack_count: row.item_stack_count, + item_durability: row.item_durability, + item_enchant_id: row.item_enchant_id, + item_soulbound: row.item_soulbound, + money: row.money, + cod: row.cod, + was_read: row.was_read, + created_at: row.created_at, + random_property_id: 0, + } + } +} + +#[cfg(test)] +#[path = "legacy_item_rows_tests.rs"] +mod tests; diff --git a/module/src/transfer/legacy_item_rows_tests.rs b/module/src/transfer/legacy_item_rows_tests.rs new file mode 100644 index 000000000..6e9139dd8 --- /dev/null +++ b/module/src/transfer/legacy_item_rows_tests.rs @@ -0,0 +1,251 @@ +use std::cell::Cell; + +use super::*; +use crate::transfer::{decode_rows, encode_rows, import_rows_via, RowIo, TransportArm}; + +fn entry(table: &str, rows: Vec) -> TableRows { + TableRows { + table: table.to_owned(), + rows: encode_rows(rows), + } +} + +fn arrive(entry: TableRows) -> Vec +where + R: SpacetimeType + for<'de> spacetimedb::sats::Deserialize<'de>, +{ + let payload = prepare(&[entry]).unwrap(); + let mut outcome = Ok(()); + let rows = decode_rows(&payload[0].rows, &mut outcome); + outcome.unwrap(); + rows +} + +#[test] +fn legacy_item_rows_keep_every_value_and_arrive_plain() { + let rows = (1..=2) + .map(|id| LegacyItemInstance { + guid: id, + entry: 509_0000 + id as u32, + owner_identity: Identity::ZERO, + owner_guid: 73, + slot: 22 + id as u8, + stack_count: id as u32, + durability: 51, + created_at: Timestamp::from_micros_since_unix_epoch(1234), + enchant_id: 7745, + soulbound: id == 1, + }) + .collect(); + let arrived: Vec = arrive(entry("game_item_instance", rows)); + assert_eq!(arrived.len(), 2); + for (index, row) in arrived.iter().enumerate() { + let id = index as u64 + 1; + assert_eq!(row.guid, id); + assert_eq!(row.entry, 509_0000 + id as u32); + assert_eq!(row.owner_identity, Identity::ZERO); + assert_eq!(row.owner_guid, 73); + assert_eq!(row.slot, 22 + id as u8); + assert_eq!(row.stack_count, id as u32); + assert_eq!(row.durability, 51); + assert_eq!( + row.created_at, + Timestamp::from_micros_since_unix_epoch(1234) + ); + assert_eq!(row.enchant_id, 7745); + assert_eq!(row.soulbound, id == 1); + assert_eq!(row.random_property_id, 0); + } +} + +#[test] +fn legacy_buyback_rows_keep_value_order_and_binding() { + let rows = vec![ + LegacyBuybackEntry { + id: 1, + player_guid: 73, + item_entry: 509_0001, + stack_count: 3, + price: 192, + soulbound: true, + }, + LegacyBuybackEntry { + id: 2, + player_guid: 73, + item_entry: 509_0002, + stack_count: 1, + price: 42, + soulbound: false, + }, + ]; + let arrived: Vec = arrive(entry("game_character_buyback", rows)); + let values: Vec<_> = arrived + .iter() + .map(|row| { + ( + row.id, + row.player_guid, + row.item_entry, + row.stack_count, + row.price, + row.soulbound, + row.random_property_id, + ) + }) + .collect(); + assert_eq!( + values, + [ + (1, 73, 509_0001, 3, 192, true, 0), + (2, 73, 509_0002, 1, 42, false, 0) + ] + ); +} + +#[test] +fn legacy_mail_decodes_multiple_variable_length_rows_without_guessing() { + let rows = [(1, "", "First body"), (2, "Second subject", "")] + .into_iter() + .map(|(id, subject, body)| LegacyMail { + id, + recipient_guid: 73, + sender_guid: 91, + subject: subject.to_owned(), + body: body.to_owned(), + item_entry: 509_0001, + item_stack_count: 3, + item_durability: 51, + item_enchant_id: 7748, + item_soulbound: false, + money: 173, + cod: 12, + was_read: id == 1, + created_at: Timestamp::from_micros_since_unix_epoch(1234), + }) + .collect(); + let arrived: Vec = arrive(entry("game_mail", rows)); + assert_eq!(arrived.len(), 2); + assert_eq!( + (&*arrived[0].subject, &*arrived[0].body), + ("", "First body") + ); + assert_eq!( + (&*arrived[1].subject, &*arrived[1].body), + ("Second subject", "") + ); + for (index, row) in arrived.iter().enumerate() { + assert_eq!(row.id, index as u64 + 1); + assert_eq!(row.recipient_guid, 73); + assert_eq!(row.sender_guid, 91); + assert_eq!(row.item_entry, 509_0001); + assert_eq!(row.item_stack_count, 3); + assert_eq!(row.item_durability, 51); + assert_eq!(row.item_enchant_id, 7748); + assert!(!row.item_soulbound); + assert_eq!(row.money, 173); + assert_eq!(row.cod, 12); + assert_eq!(row.was_read, index == 0); + assert_eq!( + row.created_at, + Timestamp::from_micros_since_unix_epoch(1234) + ); + assert_eq!(row.random_property_id, 0); + } +} + +#[test] +fn current_item_formats_preserve_the_exact_encoded_values() { + for (table, _) in FORMATS { + let mut payload = vec![TableRows { + table: (*table).to_owned(), + rows: vec![1, 2, 3, 4], + }]; + let original = payload.clone(); + mark_current(&mut payload); + assert_ne!(payload[0].table, *table); + assert_eq!(prepare(&payload).unwrap(), original); + } + let mut payload = vec![entry( + "game_character_buyback", + vec![BuybackEntry { + id: 1, + player_guid: 73, + item_entry: 509_0001, + stack_count: 1, + price: 192, + soulbound: true, + random_property_id: 1182, + }], + )]; + mark_current(&mut payload); + let arrived: Vec = arrive(payload.remove(0)); + assert_eq!(arrived[0].random_property_id, 1182); +} + +fn count_import(ctx: &Cell, _: u64, _: &mut RowIo<'_>) { + ctx.set(ctx.get() + 1); +} + +#[test] +fn the_previous_import_contract_refuses_new_formats_before_applying_rows() { + let applied = Cell::new(0); + let arms: &[TransportArm<'_, Cell>] = &[("game_item_instance", count_import)]; + let mut payload = vec![TableRows { + table: "game_item_instance".to_owned(), + rows: Vec::new(), + }]; + mark_current(&mut payload); + let error = import_rows_via(&applied, 73, &payload, arms).unwrap_err(); + assert!(error.contains("game_item_instance")); + assert_eq!(applied.get(), 0); +} + +#[test] +fn malformed_legacy_rows_and_duplicate_format_aliases_are_refused() { + let malformed = TableRows { + table: "game_mail".to_owned(), + rows: vec![1], + }; + assert!(prepare(&[malformed]) + .unwrap_err() + .contains("legacy item rows")); + let old = TableRows { + table: "game_item_instance".to_owned(), + rows: Vec::new(), + }; + let mut new = vec![old.clone()]; + mark_current(&mut new); + assert!(prepare(&[old, new.remove(0)]) + .unwrap_err() + .contains("repeats table")); +} + +#[test] +fn neither_row_format_accepts_an_unconsumed_suffix() { + let mut bytes = encode_rows(Vec::::new()); + bytes.extend_from_slice(&[1, 2, 3, 4]); + let entry = TableRows { + table: "game_mail".to_owned(), + rows: bytes.clone(), + }; + assert!(prepare(&[entry]).unwrap_err().contains("trailing bytes")); + let mut outcome = Ok(()); + let rows = decode_rows::(&bytes, &mut outcome); + assert!(rows.is_empty()); + assert!(outcome.unwrap_err().contains("trailing bytes")); +} + +#[test] +fn unrelated_and_empty_legacy_tables_keep_their_payloads() { + let payload = vec![ + TableRows { + table: "game_aura".to_owned(), + rows: vec![9, 1, 7], + }, + TableRows { + table: "game_mail".to_owned(), + rows: Vec::new(), + }, + ]; + assert_eq!(prepare(&payload).unwrap(), payload); +} diff --git a/module/src/transfer/transport.rs b/module/src/transfer/transport.rs index 237ebaa88..f2cd82619 100644 --- a/module/src/transfer/transport.rs +++ b/module/src/transfer/transport.rs @@ -12,6 +12,9 @@ use spacetimedb::{log, ReducerContext, SpacetimeType}; use super::TransferOut; +#[path = "legacy_item_rows.rs"] +mod legacy_item_rows; + /// The subset of [`crate::CHARACTER_OWNED_TABLES`] marked **hot**: state the destination needs in /// the player's first frame (worn gear, castable abilities, trained skills, the action bar). The /// rest is **cold** — correct to stream in behind the loading screen. @@ -131,10 +134,9 @@ pub struct ManifestEntry { pub hot: bool, } -/// One manifest table's ROWS, serialized. `rows` is bsatn of that table's `Vec`, -/// produced and consumed by the table's own `character_owned!(transfer, ..)` arm — the only code -/// that knows the row type. Everything between the two arms treats it as opaque bytes, which is -/// what lets ONE blob carry every table with zero per-table code in the protocol itself. +/// One manifest table's rows, encoded as BSATN. Item-bearing tables use an explicit row-format +/// suffix so an older Shard refuses data whose fields it cannot preserve. The schema manifest +/// keeps the canonical table names. Untagged item rows are upgraded when old Escrow arrives. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct TableRows { pub table: String, @@ -222,8 +224,15 @@ where if bytes.is_empty() { return Vec::new(); // the table had no rows for this character } - match spacetimedb::sats::bsatn::from_slice::>(bytes) { - Ok(rows) => rows, + let mut remaining = bytes; + match spacetimedb::sats::bsatn::from_reader::>(&mut remaining) { + Ok(rows) if remaining.is_empty() => rows, + Ok(_) => { + if outcome.is_ok() { + *outcome = Err("arriving rows contain trailing bytes".to_owned()); + } + Vec::new() + } Err(e) => { if outcome.is_ok() { *outcome = Err(format!("cannot deserialize arriving rows: {e}")); @@ -291,7 +300,9 @@ pub(crate) fn export_rows_via( /// The production binding of [`export_rows_via`]: this build's generated registry. pub(crate) fn export_rows(ctx: &ReducerContext, character_guid: u64) -> Vec { - export_rows_via(ctx, character_guid, crate::CHARACTER_OWNED_TRANSFERS) + let mut payload = export_rows_via(ctx, character_guid, crate::CHARACTER_OWNED_TRANSFERS); + legacy_item_rows::mark_current(&mut payload); + payload } /// Apply an arriving payload. Refuses (whole transaction aborts) on a table this build does not @@ -367,10 +378,11 @@ pub(crate) fn import_rows( character_guid: u64, payload: &[TableRows], ) -> Result<(), String> { + let payload = legacy_item_rows::prepare(payload)?; import_rows_via( ctx, character_guid, - payload, + &payload, crate::CHARACTER_OWNED_TRANSFERS, ) } diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs index 5490c6943..f6210ebfd 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -38,6 +38,7 @@ fn concurrent_promotions_preserve_one_roll_and_its_votes() { 1, "concurrent START calls must share one live Loot Roll: {rolls:?}" ); + assert_eq!(rolls[0]["random_property_id"], "117"); let roll_id = &rolls[0]["id"]; let initial_votes = standalone.query_rows("SELECT * FROM game_loot_roll_vote"); assert_eq!(initial_votes.len(), 3); @@ -63,6 +64,7 @@ fn concurrent_promotions_preserve_one_roll_and_its_votes() { "1", "0", "[]", + "0", ], ); let voted = votes(&standalone); @@ -104,6 +106,7 @@ fn promote(standalone: &Standalone, corpse: u64, slot: u8, item: u32, deadline: "0", &deadline.to_string(), voters, + "117", ], ); } diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs new file mode 100644 index 000000000..5eec94640 --- /dev/null +++ b/module/tests/random_properties.rs @@ -0,0 +1,158 @@ +mod support; + +use support::Standalone; + +const ITEM: &str = "5090050"; +const POOL: &str = "5090100"; +const PROPERTY: &str = "5090101"; + +fn fixture(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + shard.assert_sql("INSERT INTO game_item_random_property (property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix) VALUES (5090101,5090103,0,0,'of the Fixture'),(5090102,0,0,0,'of the Other Fixture')"); + shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,3,7,0,0)"); + shard.assert_sql("INSERT INTO game_item_property_weight (id,pool_id,property_id,weight) VALUES (5090100,5090100,5090101,10000)"); + shard.assert_sql(&format!("UPDATE game_item_template SET random_property = {POOL}, stat_stamina = 0, bonding = 0 WHERE entry = {ITEM}")); + shard +} + +fn max_health(shard: &Standalone) -> u32 { + shard.query_rows("SELECT max_health FROM game_world_entity WHERE guid = 1")[0]["max_health"] + .parse() + .unwrap() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() { + let shard = fixture("property-loot"); + shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false)")); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + shard.assert_call("debug_kill_nearest", &["1", "51000"]); + let loot = shard.query_rows(&format!( + "SELECT * FROM game_corpse_loot WHERE item_entry = {ITEM}" + )); + assert_eq!(loot.len(), 1); + assert_eq!(loot[0]["random_property_id"], PROPERTY); + shard.assert_sql( + "UPDATE game_item_property_weight SET property_id = 5090102 WHERE id = 5090100", + ); + shard.assert_call( + "debug_take_loot", + &[ + "1", + &format!("\"{}\"", loot[0]["corpse_guid"]), + &loot[0]["slot"], + ], + ); + let item = shard.query_rows(&format!( + "SELECT * FROM game_item_instance WHERE owner_guid = 1 AND entry = {ITEM}" + )); + assert_eq!(item.len(), 1); + assert_eq!(item[0]["random_property_id"], PROPERTY); + let base = max_health(&shard); + shard.assert_call("debug_equip_item", &["1", &item[0]["slot"]]); + assert_eq!( + max_health(&shard), + base + 70, + "seven Stamina above the base curve adds 70 health" + ); + shard.assert_sql( + "UPDATE game_item_instance SET enchant_id = 7748 WHERE owner_guid = 1 AND slot = 15", + ); + shard.assert_call("debug_unequip_item", &["1", "15"]); + shard.assert_call("debug_equip_item", &["1", "23"]); + assert_eq!( + max_health(&shard), + base + 100, + "the existing +3 Stamina enchant adds to the property" + ); + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 15", + ); + shard.assert_call("debug_unequip_item", &["1", "15"]); + shard.assert_call("debug_equip_item", &["1", "23"]); + assert_eq!( + max_health(&shard), + base, + "broken items grant neither overlay" + ); +} + +fn create_spell(shard: &Standalone) { + shard.assert_sql("INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES (5090100,'Fixture craft',0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,false,0,0,0,0,0,0,0)"); + shard.assert_sql(&format!("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360400,5090100,0,7,2,0,0.0,0,0,0.0,0,0,0,{ITEM},8,0,0,false)")); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn creation_is_atomic_even_when_the_spell_handles_a_full_inventory() { + let shard = fixture("property-create"); + create_spell(&shard); + shard.assert_call("debug_grant_item", &["1", ITEM, "15"]); + let before = shard.query_rows( + "SELECT guid,stack_count,random_property_id FROM game_item_instance WHERE owner_guid = 1", + ); + assert_eq!(before.len(), 15); + shard.assert_call("debug_cast_at", &["1", "5090100", "1"]); + assert_eq!(shard.query_rows("SELECT guid,stack_count,random_property_id FROM game_item_instance WHERE owner_guid = 1"), before); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + shard.assert_call("debug_cast_at", &["1", "5090100", "1"]); + let created = + shard.query_rows("SELECT random_property_id FROM game_item_instance WHERE owner_guid = 1"); + assert_eq!(created.len(), 2); + assert!(created + .iter() + .all(|item| item["random_property_id"] == PROPERTY)); + shard.assert_sql("DELETE FROM game_item_property_weight WHERE id = 5090100"); + let refused = shard.call("debug_grant_item", &["1", ITEM, "1"]); + assert!(!refused.status.success()); + assert_eq!( + shard.query_rows("SELECT random_property_id FROM game_item_instance WHERE owner_guid = 1"), + created + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn splitting_and_moving_stacks_never_mix_properties() { + let shard = fixture("property-stacks"); + shard.assert_sql(&format!( + "UPDATE game_item_template SET max_stack = 20 WHERE entry = {ITEM}" + )); + shard.assert_call("debug_grant_item", &["1", ITEM, "5"]); + shard.assert_call("debug_split_item", &["1", "23", "2", "24"]); + let split = shard.query_rows( + "SELECT stack_count,random_property_id FROM game_item_instance WHERE owner_guid = 1", + ); + assert_eq!(split.len(), 2); + assert!(split + .iter() + .all(|item| item["random_property_id"] == PROPERTY)); + shard.assert_sql( + "UPDATE game_item_property_weight SET property_id = 5090102 WHERE id = 5090100", + ); + shard.assert_call("debug_grant_item", &["1", ITEM, "4"]); + shard.assert_call("debug_move_item", &["1", "25", "24"]); + assert_eq!( + shard + .query_rows("SELECT guid FROM game_item_instance WHERE owner_guid = 1") + .len(), + 3 + ); + shard.assert_call("debug_move_item", &["1", "25", "23"]); + let merged = shard.query_rows( + "SELECT stack_count,random_property_id FROM game_item_instance WHERE owner_guid = 1", + ); + assert_eq!(merged.len(), 2); + assert!(merged + .iter() + .any(|item| item["random_property_id"] == PROPERTY && item["stack_count"] == "5")); + assert!(merged + .iter() + .any(|item| item["random_property_id"] == "5090102" && item["stack_count"] == "4")); +} From b9671fbe740a7aa3c3ce0bb8f61522f32b11ba80 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:05:47 +0000 Subject: [PATCH 2/8] feat(items): recover random property projection and import gates --- gateway/src/codec/tests.rs | 7 +- gateway/src/codec/values.rs | 2 +- gateway/src/stdb/armor.rs | 28 +-- .../src/stdb/bindings/auction_hold_type.rs | 3 + .../auction_operation_receipt_type.rs | 3 + gateway/src/stdb/bindings/auction_type.rs | 3 + .../src/stdb/bindings/buyback_entry_type.rs | 3 + gateway/src/stdb/bindings/corpse_loot_type.rs | 3 + .../bindings/game_item_enchantment_table.rs | 230 ++++++++++++++++++ .../game_item_property_weight_table.rs | 230 ++++++++++++++++++ .../game_item_random_property_table.rs | 230 ++++++++++++++++++ .../stdb/bindings/item_enchantment_type.rs | 69 ++++++ .../src/stdb/bindings/item_instance_type.rs | 3 + .../bindings/item_property_weight_type.rs | 60 +++++ .../bindings/item_random_property_type.rs | 61 +++++ .../src/stdb/bindings/item_template_type.rs | 3 + gateway/src/stdb/bindings/loot_roll_type.rs | 3 + gateway/src/stdb/bindings/mail_escrow_type.rs | 3 + gateway/src/stdb/bindings/mail_type.rs | 3 + gateway/src/stdb/bindings/mod.rs | 99 ++++++++ .../realm_auction_commit_listing_reducer.rs | 7 + .../realm_auction_refund_listing_reducer.rs | 7 + .../stdb/bindings/realm_loot_op_reducer.rs | 7 + .../bindings/realm_mail_commit_reducer.rs | 7 + .../realm_mail_item_payout_reducer.rs | 7 + gateway/src/stdb/subscriptions.rs | 10 +- gateway/src/world/handlers/loot.rs | 8 +- gateway/src/world/handlers/vendor.rs | 2 +- gateway/src/world/tests.rs | 13 +- gateway/tests/schema_parity.rs | 20 ++ importer/src/item_property.rs | 45 +++- importer/src/main.rs | 14 +- module/src/spell/effects.rs | 9 +- module/tests/random_properties.rs | 91 ++++++- 34 files changed, 1218 insertions(+), 75 deletions(-) create mode 100644 gateway/src/stdb/bindings/game_item_enchantment_table.rs create mode 100644 gateway/src/stdb/bindings/game_item_property_weight_table.rs create mode 100644 gateway/src/stdb/bindings/game_item_random_property_table.rs create mode 100644 gateway/src/stdb/bindings/item_enchantment_type.rs create mode 100644 gateway/src/stdb/bindings/item_property_weight_type.rs create mode 100644 gateway/src/stdb/bindings/item_random_property_type.rs diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index b55bec3b0..c71380608 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -1832,7 +1832,7 @@ fn player_create_seeds_inventory_slot_descriptor() { let msg = build_create_object( &warrior_entity(), CreateKind::SelfPlayer, - &[(23, item_guid, 25)], + &[(23, item_guid, 25, 0)], &[], ) .unwrap(); @@ -1944,7 +1944,7 @@ fn player_create_equips_mainhand_renders_visible_item() { let msg = build_create_object( &warrior_entity(), CreateKind::SelfPlayer, - &[(15, item_guid, 25)], + &[(15, item_guid, 25, 0)], &[], ) .unwrap(); @@ -3166,7 +3166,7 @@ fn visible_item_index_boundary_stops_after_equipment_slot_18() { let msg = build_create_object( &warrior_entity(), CreateKind::SelfPlayer, - &[(18, g18, 100), (19, g19, 200)], + &[(18, g18, 100, 0), (19, g19, 200, 0)], &[], ) .unwrap(); @@ -3702,7 +3702,6 @@ fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { mask1: UpdateMask::Player(p), .. } => { - assert!(p.object_type().is_none()); let vi = p .player_visible_item(VisibleItemIndex::Index15) .expect("visible item set"); diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index edcfb9537..1fd9963ce 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -660,7 +660,7 @@ mod lint_tests { ), ( "visible_item", - build_visible_item_values(g, 15, 25, 0).unwrap(), + build_visible_item_values(g, 15, 25, 117).unwrap(), ), ("item", build_item_values(0x4000_0000_0000_0001, 5, 70)), ]; diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index cfffb865c..5e13d1a0c 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -1,28 +1,6 @@ -//! The gateway-side EFFECTIVE-armor fold (Approach B) for the character sheet's "Armor" readout -//! (`UNIT_FIELD_RESISTANCES[0]`). The MODULE is left byte-identical: `module::combat::effective_armor` -//! stays an on-demand combat fold and `e.armor` stays BASE (agility*2), so physical-mitigation is -//! untouched. This recomputes the same EFFECTIVE value the sheet must DISPLAY — base armor + every -//! `A_MOD_RESISTANCE(armor)` aura + the armor summed across equipped gear — purely from a connection's -//! subscription cache, so no module change is needed to drive it live. -//! -//! The SAME fold runs at two sites with two caches: -//! - player CREATE on the COORDINATOR cache (base + gear; `game_aura` isn't in that cache → the aura -//! term is 0, which the on_aura relay corrects the instant a login-present aura inserts), and -//! - the aura / gear relays on the PER-PLAYER cache (base + auras + gear; all three tables subscribed). -//! -//! It mirrors the Module's armor fold, including catalogue effects for applied enchantments -//! and Random Properties. The other character-sheet values below come directly from the Module. -//! -//! [`sheet_stats`] (the STR/AGI/STA/INT/SPI/AP/damage-range/crit half of the paperdoll, #517 + #532) is -//! NOT a gateway-side fold like the Armor half above — it is a plain READ of -//! `module::spell::recompute_sheet`'s output, END-appended onto `game_world_entity` (`sheet_*_bonus`/ -//! `sheet_ap_base`/`sheet_ap_mods`/`sheet_dmg_min`/`sheet_dmg_max`/`sheet_crit_bp` and the ranged -//! attack-power/damage projection). Those columns -//! already ride the player CREATE relay (the same row), so — unlike Armor — there is no -//! coordinator-cache gap to patch: an aura present at login already shows on the very first CREATE, no -//! on-aura re-push needed. Do NOT re-introduce a second aura/gear fold here for those numbers; extend -//! `recompute_sheet` instead (that's the whole point of #517 — the previous gateway-only mirror never -//! read `game_aura`, so no aura could ever move the sheet). +//! Character-sheet armor from the coordinator's entity, aura, item and catalogue caches. +//! The fold mirrors Module combat armor, including applied enchantments and Random Properties. +//! Other character-sheet values come directly from the Module's derived entity fields. use super::bindings::*; use spacetimedb_sdk::Table; diff --git a/gateway/src/stdb/bindings/auction_hold_type.rs b/gateway/src/stdb/bindings/auction_hold_type.rs index ccdec47a4..d762212e4 100644 --- a/gateway/src/stdb/bindings/auction_hold_type.rs +++ b/gateway/src/stdb/bindings/auction_hold_type.rs @@ -24,6 +24,7 @@ pub struct AuctionHold { pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, + pub random_property_id: u32, } impl __sdk::InModule for AuctionHold { @@ -51,6 +52,7 @@ pub struct AuctionHoldCols { pub house: __sdk::__query_builder::Col, pub deposit_rate: __sdk::__query_builder::Col, pub consignment_rate: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for AuctionHold { @@ -74,6 +76,7 @@ impl __sdk::__query_builder::HasCols for AuctionHold { house: __sdk::__query_builder::Col::new(table_name, "house"), deposit_rate: __sdk::__query_builder::Col::new(table_name, "deposit_rate"), consignment_rate: __sdk::__query_builder::Col::new(table_name, "consignment_rate"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/auction_operation_receipt_type.rs b/gateway/src/stdb/bindings/auction_operation_receipt_type.rs index 6dead3942..b03c5458e 100644 --- a/gateway/src/stdb/bindings/auction_operation_receipt_type.rs +++ b/gateway/src/stdb/bindings/auction_operation_receipt_type.rs @@ -25,6 +25,7 @@ pub struct AuctionOperationReceipt { pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, + pub random_property_id: u32, } impl __sdk::InModule for AuctionOperationReceipt { @@ -53,6 +54,7 @@ pub struct AuctionOperationReceiptCols { pub house: __sdk::__query_builder::Col, pub deposit_rate: __sdk::__query_builder::Col, pub consignment_rate: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for AuctionOperationReceipt { @@ -77,6 +79,7 @@ impl __sdk::__query_builder::HasCols for AuctionOperationReceipt { house: __sdk::__query_builder::Col::new(table_name, "house"), deposit_rate: __sdk::__query_builder::Col::new(table_name, "deposit_rate"), consignment_rate: __sdk::__query_builder::Col::new(table_name, "consignment_rate"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/auction_type.rs b/gateway/src/stdb/bindings/auction_type.rs index a75d82d28..6b3459fce 100644 --- a/gateway/src/stdb/bindings/auction_type.rs +++ b/gateway/src/stdb/bindings/auction_type.rs @@ -27,6 +27,7 @@ pub struct Auction { pub revision: u64, pub deposit_rate: u32, pub consignment_rate: u32, + pub random_property_id: u32, } impl __sdk::InModule for Auction { @@ -57,6 +58,7 @@ pub struct AuctionCols { pub revision: __sdk::__query_builder::Col, pub deposit_rate: __sdk::__query_builder::Col, pub consignment_rate: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for Auction { @@ -89,6 +91,7 @@ impl __sdk::__query_builder::HasCols for Auction { revision: __sdk::__query_builder::Col::new(table_name, "revision"), deposit_rate: __sdk::__query_builder::Col::new(table_name, "deposit_rate"), consignment_rate: __sdk::__query_builder::Col::new(table_name, "consignment_rate"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/buyback_entry_type.rs b/gateway/src/stdb/bindings/buyback_entry_type.rs index 1c3473319..d14f510b5 100644 --- a/gateway/src/stdb/bindings/buyback_entry_type.rs +++ b/gateway/src/stdb/bindings/buyback_entry_type.rs @@ -13,6 +13,7 @@ pub struct BuybackEntry { pub stack_count: u32, pub price: u32, pub soulbound: bool, + pub random_property_id: u32, } impl __sdk::InModule for BuybackEntry { @@ -29,6 +30,7 @@ pub struct BuybackEntryCols { pub stack_count: __sdk::__query_builder::Col, pub price: __sdk::__query_builder::Col, pub soulbound: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for BuybackEntry { @@ -41,6 +43,7 @@ impl __sdk::__query_builder::HasCols for BuybackEntry { stack_count: __sdk::__query_builder::Col::new(table_name, "stack_count"), price: __sdk::__query_builder::Col::new(table_name, "price"), soulbound: __sdk::__query_builder::Col::new(table_name, "soulbound"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/corpse_loot_type.rs b/gateway/src/stdb/bindings/corpse_loot_type.rs index 971cefc7f..1d8ff5413 100644 --- a/gateway/src/stdb/bindings/corpse_loot_type.rs +++ b/gateway/src/stdb/bindings/corpse_loot_type.rs @@ -17,6 +17,7 @@ pub struct CorpseLoot { pub designated_looter_guid: u64, pub master_only: bool, pub withheld: bool, + pub random_property_id: u32, } impl __sdk::InModule for CorpseLoot { @@ -37,6 +38,7 @@ pub struct CorpseLootCols { pub designated_looter_guid: __sdk::__query_builder::Col, pub master_only: __sdk::__query_builder::Col, pub withheld: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for CorpseLoot { @@ -56,6 +58,7 @@ impl __sdk::__query_builder::HasCols for CorpseLoot { ), master_only: __sdk::__query_builder::Col::new(table_name, "master_only"), withheld: __sdk::__query_builder::Col::new(table_name, "withheld"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/game_item_enchantment_table.rs b/gateway/src/stdb/bindings/game_item_enchantment_table.rs new file mode 100644 index 000000000..9f3320b1d --- /dev/null +++ b/gateway/src/stdb/bindings/game_item_enchantment_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::item_enchantment_type::ItemEnchantment; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_item_enchantment`. +/// +/// Obtain a handle from the [`GameItemEnchantmentTableAccess::game_item_enchantment`] method on [`super::RemoteTables`], +/// like `ctx.db.game_item_enchantment()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_enchantment().on_insert(...)`. +pub struct GameItemEnchantmentTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_item_enchantment`. +pub struct GameItemEnchantmentTableAccessor; + +impl __sdk::TableAccessor for GameItemEnchantmentTableAccessor { + type Row = ItemEnchantment; + type Handle<'db> = GameItemEnchantmentTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_item_enchantment() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_item_enchantment`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameItemEnchantmentTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameItemEnchantmentTableHandle`], which mediates access to the table `game_item_enchantment`. + fn game_item_enchantment(&self) -> GameItemEnchantmentTableHandle<'_>; +} + +impl GameItemEnchantmentTableAccess for super::RemoteTables { + fn game_item_enchantment(&self) -> GameItemEnchantmentTableHandle<'_> { + GameItemEnchantmentTableHandle { + imp: self + .imp + .get_table::("game_item_enchantment"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameItemEnchantmentInsertCallbackId(__sdk::CallbackId); +pub struct GameItemEnchantmentDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameItemEnchantmentTableHandle<'ctx> { + type Row = ItemEnchantment; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameItemEnchantmentTableHandle<'ctx> { + type Row = ItemEnchantment; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameItemEnchantmentInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentInsertCallbackId { + GameItemEnchantmentInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemEnchantmentInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameItemEnchantmentDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentDeleteCallbackId { + GameItemEnchantmentDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemEnchantmentDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameItemEnchantmentTableHandle<'ctx> { + type InsertCallbackId = GameItemEnchantmentInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentInsertCallbackId { + GameItemEnchantmentInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemEnchantmentInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameItemEnchantmentTableHandle<'ctx> { + type DeleteCallbackId = GameItemEnchantmentDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentDeleteCallbackId { + GameItemEnchantmentDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemEnchantmentDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameItemEnchantmentUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameItemEnchantmentTableHandle<'ctx> { + type UpdateCallbackId = GameItemEnchantmentUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentUpdateCallbackId { + GameItemEnchantmentUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemEnchantmentUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameItemEnchantmentTableHandle<'ctx> { + type UpdateCallbackId = GameItemEnchantmentUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemEnchantmentUpdateCallbackId { + GameItemEnchantmentUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemEnchantmentUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `id` unique index on the table `game_item_enchantment`, +/// which allows point queries on the field of the same name +/// via the [`GameItemEnchantmentIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_enchantment().id().find(...)`. +pub struct GameItemEnchantmentIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameItemEnchantmentTableHandle<'ctx> { + /// Get a handle on the `id` unique index on the table `game_item_enchantment`. + pub fn id(&self) -> GameItemEnchantmentIdUnique<'ctx> { + GameItemEnchantmentIdUnique { + imp: self.imp.get_unique_constraint::("id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameItemEnchantmentIdUnique<'ctx> { + /// Find the subscribed row whose `id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_item_enchantment"); + _table.add_unique_constraint::("id", |row| &row.id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ItemEnchantment`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_item_enchantmentQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ItemEnchantment`. + fn game_item_enchantment(&self) -> __sdk::__query_builder::Table; +} + +impl game_item_enchantmentQueryTableAccess for __sdk::QueryTableAccessor { + fn game_item_enchantment(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_item_enchantment") + } +} diff --git a/gateway/src/stdb/bindings/game_item_property_weight_table.rs b/gateway/src/stdb/bindings/game_item_property_weight_table.rs new file mode 100644 index 000000000..9522e715c --- /dev/null +++ b/gateway/src/stdb/bindings/game_item_property_weight_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::item_property_weight_type::ItemPropertyWeight; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_item_property_weight`. +/// +/// Obtain a handle from the [`GameItemPropertyWeightTableAccess::game_item_property_weight`] method on [`super::RemoteTables`], +/// like `ctx.db.game_item_property_weight()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_property_weight().on_insert(...)`. +pub struct GameItemPropertyWeightTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_item_property_weight`. +pub struct GameItemPropertyWeightTableAccessor; + +impl __sdk::TableAccessor for GameItemPropertyWeightTableAccessor { + type Row = ItemPropertyWeight; + type Handle<'db> = GameItemPropertyWeightTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_item_property_weight() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_item_property_weight`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameItemPropertyWeightTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameItemPropertyWeightTableHandle`], which mediates access to the table `game_item_property_weight`. + fn game_item_property_weight(&self) -> GameItemPropertyWeightTableHandle<'_>; +} + +impl GameItemPropertyWeightTableAccess for super::RemoteTables { + fn game_item_property_weight(&self) -> GameItemPropertyWeightTableHandle<'_> { + GameItemPropertyWeightTableHandle { + imp: self + .imp + .get_table::("game_item_property_weight"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameItemPropertyWeightInsertCallbackId(__sdk::CallbackId); +pub struct GameItemPropertyWeightDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameItemPropertyWeightTableHandle<'ctx> { + type Row = ItemPropertyWeight; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameItemPropertyWeightTableHandle<'ctx> { + type Row = ItemPropertyWeight; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameItemPropertyWeightInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightInsertCallbackId { + GameItemPropertyWeightInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemPropertyWeightInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameItemPropertyWeightDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightDeleteCallbackId { + GameItemPropertyWeightDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemPropertyWeightDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameItemPropertyWeightTableHandle<'ctx> { + type InsertCallbackId = GameItemPropertyWeightInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightInsertCallbackId { + GameItemPropertyWeightInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemPropertyWeightInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameItemPropertyWeightTableHandle<'ctx> { + type DeleteCallbackId = GameItemPropertyWeightDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightDeleteCallbackId { + GameItemPropertyWeightDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemPropertyWeightDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameItemPropertyWeightUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameItemPropertyWeightTableHandle<'ctx> { + type UpdateCallbackId = GameItemPropertyWeightUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightUpdateCallbackId { + GameItemPropertyWeightUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemPropertyWeightUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameItemPropertyWeightTableHandle<'ctx> { + type UpdateCallbackId = GameItemPropertyWeightUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemPropertyWeightUpdateCallbackId { + GameItemPropertyWeightUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemPropertyWeightUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `id` unique index on the table `game_item_property_weight`, +/// which allows point queries on the field of the same name +/// via the [`GameItemPropertyWeightIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_property_weight().id().find(...)`. +pub struct GameItemPropertyWeightIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameItemPropertyWeightTableHandle<'ctx> { + /// Get a handle on the `id` unique index on the table `game_item_property_weight`. + pub fn id(&self) -> GameItemPropertyWeightIdUnique<'ctx> { + GameItemPropertyWeightIdUnique { + imp: self.imp.get_unique_constraint::("id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameItemPropertyWeightIdUnique<'ctx> { + /// Find the subscribed row whose `id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_item_property_weight"); + _table.add_unique_constraint::("id", |row| &row.id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ItemPropertyWeight`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_item_property_weightQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ItemPropertyWeight`. + fn game_item_property_weight(&self) -> __sdk::__query_builder::Table; +} + +impl game_item_property_weightQueryTableAccess for __sdk::QueryTableAccessor { + fn game_item_property_weight(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_item_property_weight") + } +} diff --git a/gateway/src/stdb/bindings/game_item_random_property_table.rs b/gateway/src/stdb/bindings/game_item_random_property_table.rs new file mode 100644 index 000000000..a70f11922 --- /dev/null +++ b/gateway/src/stdb/bindings/game_item_random_property_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::item_random_property_type::ItemRandomProperty; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_item_random_property`. +/// +/// Obtain a handle from the [`GameItemRandomPropertyTableAccess::game_item_random_property`] method on [`super::RemoteTables`], +/// like `ctx.db.game_item_random_property()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_random_property().on_insert(...)`. +pub struct GameItemRandomPropertyTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_item_random_property`. +pub struct GameItemRandomPropertyTableAccessor; + +impl __sdk::TableAccessor for GameItemRandomPropertyTableAccessor { + type Row = ItemRandomProperty; + type Handle<'db> = GameItemRandomPropertyTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_item_random_property() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_item_random_property`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameItemRandomPropertyTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameItemRandomPropertyTableHandle`], which mediates access to the table `game_item_random_property`. + fn game_item_random_property(&self) -> GameItemRandomPropertyTableHandle<'_>; +} + +impl GameItemRandomPropertyTableAccess for super::RemoteTables { + fn game_item_random_property(&self) -> GameItemRandomPropertyTableHandle<'_> { + GameItemRandomPropertyTableHandle { + imp: self + .imp + .get_table::("game_item_random_property"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameItemRandomPropertyInsertCallbackId(__sdk::CallbackId); +pub struct GameItemRandomPropertyDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameItemRandomPropertyTableHandle<'ctx> { + type Row = ItemRandomProperty; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameItemRandomPropertyTableHandle<'ctx> { + type Row = ItemRandomProperty; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameItemRandomPropertyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyInsertCallbackId { + GameItemRandomPropertyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemRandomPropertyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameItemRandomPropertyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyDeleteCallbackId { + GameItemRandomPropertyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemRandomPropertyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameItemRandomPropertyTableHandle<'ctx> { + type InsertCallbackId = GameItemRandomPropertyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyInsertCallbackId { + GameItemRandomPropertyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameItemRandomPropertyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameItemRandomPropertyTableHandle<'ctx> { + type DeleteCallbackId = GameItemRandomPropertyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyDeleteCallbackId { + GameItemRandomPropertyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameItemRandomPropertyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameItemRandomPropertyUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameItemRandomPropertyTableHandle<'ctx> { + type UpdateCallbackId = GameItemRandomPropertyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyUpdateCallbackId { + GameItemRandomPropertyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemRandomPropertyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameItemRandomPropertyTableHandle<'ctx> { + type UpdateCallbackId = GameItemRandomPropertyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameItemRandomPropertyUpdateCallbackId { + GameItemRandomPropertyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameItemRandomPropertyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `property_id` unique index on the table `game_item_random_property`, +/// which allows point queries on the field of the same name +/// via the [`GameItemRandomPropertyPropertyIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_item_random_property().property_id().find(...)`. +pub struct GameItemRandomPropertyPropertyIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameItemRandomPropertyTableHandle<'ctx> { + /// Get a handle on the `property_id` unique index on the table `game_item_random_property`. + pub fn property_id(&self) -> GameItemRandomPropertyPropertyIdUnique<'ctx> { + GameItemRandomPropertyPropertyIdUnique { + imp: self.imp.get_unique_constraint::("property_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameItemRandomPropertyPropertyIdUnique<'ctx> { + /// Find the subscribed row whose `property_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u32) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_item_random_property"); + _table.add_unique_constraint::("property_id", |row| &row.property_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ItemRandomProperty`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_item_random_propertyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ItemRandomProperty`. + fn game_item_random_property(&self) -> __sdk::__query_builder::Table; +} + +impl game_item_random_propertyQueryTableAccess for __sdk::QueryTableAccessor { + fn game_item_random_property(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_item_random_property") + } +} diff --git a/gateway/src/stdb/bindings/item_enchantment_type.rs b/gateway/src/stdb/bindings/item_enchantment_type.rs new file mode 100644 index 000000000..52461d20d --- /dev/null +++ b/gateway/src/stdb/bindings/item_enchantment_type.rs @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ItemEnchantment { + pub id: u64, + pub enchant_id: u32, + pub effect_index: u8, + pub kind: u8, + pub amount: i32, + pub spell_id: u32, + pub school_mask: u32, +} + +impl __sdk::InModule for ItemEnchantment { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ItemEnchantment`. +/// +/// Provides typed access to columns for query building. +pub struct ItemEnchantmentCols { + pub id: __sdk::__query_builder::Col, + pub enchant_id: __sdk::__query_builder::Col, + pub effect_index: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, + pub amount: __sdk::__query_builder::Col, + pub spell_id: __sdk::__query_builder::Col, + pub school_mask: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ItemEnchantment { + type Cols = ItemEnchantmentCols; + fn cols(table_name: &'static str) -> Self::Cols { + ItemEnchantmentCols { + id: __sdk::__query_builder::Col::new(table_name, "id"), + enchant_id: __sdk::__query_builder::Col::new(table_name, "enchant_id"), + effect_index: __sdk::__query_builder::Col::new(table_name, "effect_index"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + amount: __sdk::__query_builder::Col::new(table_name, "amount"), + spell_id: __sdk::__query_builder::Col::new(table_name, "spell_id"), + school_mask: __sdk::__query_builder::Col::new(table_name, "school_mask"), + } + } +} + +/// Indexed column accessor struct for the table `ItemEnchantment`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ItemEnchantmentIxCols { + pub enchant_id: __sdk::__query_builder::IxCol, + pub id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ItemEnchantment { + type IxCols = ItemEnchantmentIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ItemEnchantmentIxCols { + enchant_id: __sdk::__query_builder::IxCol::new(table_name, "enchant_id"), + id: __sdk::__query_builder::IxCol::new(table_name, "id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ItemEnchantment {} diff --git a/gateway/src/stdb/bindings/item_instance_type.rs b/gateway/src/stdb/bindings/item_instance_type.rs index daa8eec76..ee00b46e5 100644 --- a/gateway/src/stdb/bindings/item_instance_type.rs +++ b/gateway/src/stdb/bindings/item_instance_type.rs @@ -17,6 +17,7 @@ pub struct ItemInstance { pub created_at: __sdk::Timestamp, pub enchant_id: u32, pub soulbound: bool, + pub random_property_id: u32, } impl __sdk::InModule for ItemInstance { @@ -37,6 +38,7 @@ pub struct ItemInstanceCols { pub created_at: __sdk::__query_builder::Col, pub enchant_id: __sdk::__query_builder::Col, pub soulbound: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ItemInstance { @@ -53,6 +55,7 @@ impl __sdk::__query_builder::HasCols for ItemInstance { created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), enchant_id: __sdk::__query_builder::Col::new(table_name, "enchant_id"), soulbound: __sdk::__query_builder::Col::new(table_name, "soulbound"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/item_property_weight_type.rs b/gateway/src/stdb/bindings/item_property_weight_type.rs new file mode 100644 index 000000000..b604ec2be --- /dev/null +++ b/gateway/src/stdb/bindings/item_property_weight_type.rs @@ -0,0 +1,60 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ItemPropertyWeight { + pub id: u64, + pub pool_id: u32, + pub property_id: u32, + pub weight: u32, +} + +impl __sdk::InModule for ItemPropertyWeight { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ItemPropertyWeight`. +/// +/// Provides typed access to columns for query building. +pub struct ItemPropertyWeightCols { + pub id: __sdk::__query_builder::Col, + pub pool_id: __sdk::__query_builder::Col, + pub property_id: __sdk::__query_builder::Col, + pub weight: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ItemPropertyWeight { + type Cols = ItemPropertyWeightCols; + fn cols(table_name: &'static str) -> Self::Cols { + ItemPropertyWeightCols { + id: __sdk::__query_builder::Col::new(table_name, "id"), + pool_id: __sdk::__query_builder::Col::new(table_name, "pool_id"), + property_id: __sdk::__query_builder::Col::new(table_name, "property_id"), + weight: __sdk::__query_builder::Col::new(table_name, "weight"), + } + } +} + +/// Indexed column accessor struct for the table `ItemPropertyWeight`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ItemPropertyWeightIxCols { + pub id: __sdk::__query_builder::IxCol, + pub pool_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ItemPropertyWeight { + type IxCols = ItemPropertyWeightIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ItemPropertyWeightIxCols { + id: __sdk::__query_builder::IxCol::new(table_name, "id"), + pool_id: __sdk::__query_builder::IxCol::new(table_name, "pool_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ItemPropertyWeight {} diff --git a/gateway/src/stdb/bindings/item_random_property_type.rs b/gateway/src/stdb/bindings/item_random_property_type.rs new file mode 100644 index 000000000..51ce2e5e8 --- /dev/null +++ b/gateway/src/stdb/bindings/item_random_property_type.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ItemRandomProperty { + pub property_id: u32, + pub enchant_id_1: u32, + pub enchant_id_2: u32, + pub enchant_id_3: u32, + pub suffix: String, +} + +impl __sdk::InModule for ItemRandomProperty { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ItemRandomProperty`. +/// +/// Provides typed access to columns for query building. +pub struct ItemRandomPropertyCols { + pub property_id: __sdk::__query_builder::Col, + pub enchant_id_1: __sdk::__query_builder::Col, + pub enchant_id_2: __sdk::__query_builder::Col, + pub enchant_id_3: __sdk::__query_builder::Col, + pub suffix: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ItemRandomProperty { + type Cols = ItemRandomPropertyCols; + fn cols(table_name: &'static str) -> Self::Cols { + ItemRandomPropertyCols { + property_id: __sdk::__query_builder::Col::new(table_name, "property_id"), + enchant_id_1: __sdk::__query_builder::Col::new(table_name, "enchant_id_1"), + enchant_id_2: __sdk::__query_builder::Col::new(table_name, "enchant_id_2"), + enchant_id_3: __sdk::__query_builder::Col::new(table_name, "enchant_id_3"), + suffix: __sdk::__query_builder::Col::new(table_name, "suffix"), + } + } +} + +/// Indexed column accessor struct for the table `ItemRandomProperty`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ItemRandomPropertyIxCols { + pub property_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ItemRandomProperty { + type IxCols = ItemRandomPropertyIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ItemRandomPropertyIxCols { + property_id: __sdk::__query_builder::IxCol::new(table_name, "property_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ItemRandomProperty {} diff --git a/gateway/src/stdb/bindings/item_template_type.rs b/gateway/src/stdb/bindings/item_template_type.rs index 327e8c738..fd209c646 100644 --- a/gateway/src/stdb/bindings/item_template_type.rs +++ b/gateway/src/stdb/bindings/item_template_type.rs @@ -65,6 +65,7 @@ pub struct ItemTemplate { pub food_type: u8, pub allowed_class: u32, pub allowed_race: u32, + pub random_property: u32, } impl __sdk::InModule for ItemTemplate { @@ -133,6 +134,7 @@ pub struct ItemTemplateCols { pub food_type: __sdk::__query_builder::Col, pub allowed_class: __sdk::__query_builder::Col, pub allowed_race: __sdk::__query_builder::Col, + pub random_property: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ItemTemplate { @@ -206,6 +208,7 @@ impl __sdk::__query_builder::HasCols for ItemTemplate { food_type: __sdk::__query_builder::Col::new(table_name, "food_type"), allowed_class: __sdk::__query_builder::Col::new(table_name, "allowed_class"), allowed_race: __sdk::__query_builder::Col::new(table_name, "allowed_race"), + random_property: __sdk::__query_builder::Col::new(table_name, "random_property"), } } } diff --git a/gateway/src/stdb/bindings/loot_roll_type.rs b/gateway/src/stdb/bindings/loot_roll_type.rs index 9f22ca06d..2fa369e22 100644 --- a/gateway/src/stdb/bindings/loot_roll_type.rs +++ b/gateway/src/stdb/bindings/loot_roll_type.rs @@ -13,6 +13,7 @@ pub struct LootRoll { pub item_entry: u32, pub deadline_micros: i64, pub resolved: bool, + pub random_property_id: u32, } impl __sdk::InModule for LootRoll { @@ -29,6 +30,7 @@ pub struct LootRollCols { pub item_entry: __sdk::__query_builder::Col, pub deadline_micros: __sdk::__query_builder::Col, pub resolved: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for LootRoll { @@ -41,6 +43,7 @@ impl __sdk::__query_builder::HasCols for LootRoll { item_entry: __sdk::__query_builder::Col::new(table_name, "item_entry"), deadline_micros: __sdk::__query_builder::Col::new(table_name, "deadline_micros"), resolved: __sdk::__query_builder::Col::new(table_name, "resolved"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/mail_escrow_type.rs b/gateway/src/stdb/bindings/mail_escrow_type.rs index 71b77d1f7..cdbe0566d 100644 --- a/gateway/src/stdb/bindings/mail_escrow_type.rs +++ b/gateway/src/stdb/bindings/mail_escrow_type.rs @@ -24,6 +24,7 @@ pub struct MailEscrow { pub item_enchant_id: u32, pub item_soulbound: bool, pub cod: u32, + pub random_property_id: u32, } impl __sdk::InModule for MailEscrow { @@ -51,6 +52,7 @@ pub struct MailEscrowCols { pub item_enchant_id: __sdk::__query_builder::Col, pub item_soulbound: __sdk::__query_builder::Col, pub cod: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for MailEscrow { @@ -74,6 +76,7 @@ impl __sdk::__query_builder::HasCols for MailEscrow { item_enchant_id: __sdk::__query_builder::Col::new(table_name, "item_enchant_id"), item_soulbound: __sdk::__query_builder::Col::new(table_name, "item_soulbound"), cod: __sdk::__query_builder::Col::new(table_name, "cod"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/mail_type.rs b/gateway/src/stdb/bindings/mail_type.rs index 8d3a41c11..3e5161a55 100644 --- a/gateway/src/stdb/bindings/mail_type.rs +++ b/gateway/src/stdb/bindings/mail_type.rs @@ -21,6 +21,7 @@ pub struct Mail { pub cod: u32, pub was_read: bool, pub created_at: __sdk::Timestamp, + pub random_property_id: u32, } impl __sdk::InModule for Mail { @@ -45,6 +46,7 @@ pub struct MailCols { pub cod: __sdk::__query_builder::Col, pub was_read: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, + pub random_property_id: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for Mail { @@ -65,6 +67,7 @@ impl __sdk::__query_builder::HasCols for Mail { cod: __sdk::__query_builder::Col::new(table_name, "cod"), was_read: __sdk::__query_builder::Col::new(table_name, "was_read"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + random_property_id: __sdk::__query_builder::Col::new(table_name, "random_property_id"), } } } diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 2dd266c44..318f23993 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -441,7 +441,10 @@ pub mod game_instance_binding_type; pub mod game_instance_reaper_schedule_table; pub mod game_instance_table; pub mod game_instance_type; +pub mod game_item_enchantment_table; pub mod game_item_instance_table; +pub mod game_item_property_weight_table; +pub mod game_item_random_property_table; pub mod game_item_template_table; pub mod game_level_stats_table; pub mod game_levelup_event_table; @@ -715,7 +718,10 @@ pub mod install_guid_range_reducer; pub mod instance_reaper_schedule_type; pub mod instruction_selection_type; pub mod instruction_target_type; +pub mod item_enchantment_type; pub mod item_instance_type; +pub mod item_property_weight_type; +pub mod item_random_property_type; pub mod item_template_type; pub mod kill_condition_type; pub mod kill_credit_type; @@ -1408,7 +1414,10 @@ pub use game_instance_binding_type::GameInstanceBinding; pub use game_instance_reaper_schedule_table::*; pub use game_instance_table::*; pub use game_instance_type::GameInstance; +pub use game_item_enchantment_table::*; pub use game_item_instance_table::*; +pub use game_item_property_weight_table::*; +pub use game_item_random_property_table::*; pub use game_item_template_table::*; pub use game_level_stats_table::*; pub use game_levelup_event_table::*; @@ -1682,7 +1691,10 @@ pub use install_guid_range_reducer::install_guid_range; pub use instance_reaper_schedule_type::InstanceReaperSchedule; pub use instruction_selection_type::InstructionSelection; pub use instruction_target_type::InstructionTarget; +pub use item_enchantment_type::ItemEnchantment; pub use item_instance_type::ItemInstance; +pub use item_property_weight_type::ItemPropertyWeight; +pub use item_random_property_type::ItemRandomProperty; pub use item_template_type::ItemTemplate; pub use kill_condition_type::KillCondition; pub use kill_credit_type::KillCredit; @@ -3248,6 +3260,7 @@ pub enum Reducer { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -3286,6 +3299,7 @@ pub enum Reducer { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -3315,6 +3329,7 @@ pub enum Reducer { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, }, RealmMailCommit { escrow_id: u64, @@ -3328,6 +3343,7 @@ pub enum Reducer { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, cod: u32, cod_mail_id: u64, }, @@ -3359,6 +3375,7 @@ pub enum Reducer { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, }, RealmMailItemRoom { payee_guid: u64, @@ -6265,6 +6282,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, @@ -6283,6 +6301,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability: item_durability.clone(), item_enchant_id: item_enchant_id.clone(), item_soulbound: item_soulbound.clone(), + random_property_id: random_property_id.clone(), house: house.clone(), deposit_rate: deposit_rate.clone(), consignment_rate: consignment_rate.clone(), @@ -6337,6 +6356,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, @@ -6355,6 +6375,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability: item_durability.clone(), item_enchant_id: item_enchant_id.clone(), item_soulbound: item_soulbound.clone(), + random_property_id: random_property_id.clone(), house: house.clone(), deposit_rate: deposit_rate.clone(), consignment_rate: consignment_rate.clone(), @@ -6392,6 +6413,7 @@ Reducer::PrepareVmapNavCoverage{ vote, deadline_micros, recipients, + random_property_id, } => __sats::bsatn::to_vec(&realm_loot_op_reducer::RealmLootOpArgs { op: op.clone(), corpse_guid: corpse_guid.clone(), @@ -6401,6 +6423,7 @@ Reducer::PrepareVmapNavCoverage{ vote: vote.clone(), deadline_micros: deadline_micros.clone(), recipients: recipients.clone(), + random_property_id: random_property_id.clone(), }), Reducer::RealmMailCommit{ escrow_id, @@ -6414,6 +6437,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability, item_enchant_id, item_soulbound, + random_property_id, cod, cod_mail_id, } => __sats::bsatn::to_vec(&realm_mail_commit_reducer::RealmMailCommitArgs { @@ -6428,6 +6452,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability: item_durability.clone(), item_enchant_id: item_enchant_id.clone(), item_soulbound: item_soulbound.clone(), + random_property_id: random_property_id.clone(), cod: cod.clone(), cod_mail_id: cod_mail_id.clone(), }), @@ -6475,6 +6500,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability, item_enchant_id, item_soulbound, + random_property_id, } => __sats::bsatn::to_vec(&realm_mail_item_payout_reducer::RealmMailItemPayoutArgs { escrow_id: escrow_id.clone(), payee_guid: payee_guid.clone(), @@ -6484,6 +6510,7 @@ Reducer::PrepareVmapNavCoverage{ item_durability: item_durability.clone(), item_enchant_id: item_enchant_id.clone(), item_soulbound: item_soulbound.clone(), + random_property_id: random_property_id.clone(), }), Reducer::RealmMailItemRoom{ payee_guid, @@ -6948,7 +6975,10 @@ pub struct DbUpdate { game_instance: __sdk::TableUpdate, game_instance_binding: __sdk::TableUpdate, game_instance_reaper_schedule: __sdk::TableUpdate, + game_item_enchantment: __sdk::TableUpdate, game_item_instance: __sdk::TableUpdate, + game_item_property_weight: __sdk::TableUpdate, + game_item_random_property: __sdk::TableUpdate, game_item_template: __sdk::TableUpdate, game_level_stats: __sdk::TableUpdate, game_levelup_event: __sdk::TableUpdate, @@ -7512,9 +7542,18 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_instance_reaper_schedule" => db_update.game_instance_reaper_schedule.append( game_instance_reaper_schedule_table::parse_table_update(table_update)?, ), + "game_item_enchantment" => db_update.game_item_enchantment.append( + game_item_enchantment_table::parse_table_update(table_update)?, + ), "game_item_instance" => db_update .game_item_instance .append(game_item_instance_table::parse_table_update(table_update)?), + "game_item_property_weight" => db_update.game_item_property_weight.append( + game_item_property_weight_table::parse_table_update(table_update)?, + ), + "game_item_random_property" => db_update.game_item_random_property.append( + game_item_random_property_table::parse_table_update(table_update)?, + ), "game_item_template" => db_update .game_item_template .append(game_item_template_table::parse_table_update(table_update)?), @@ -8502,9 +8541,27 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_instance_reaper_schedule, ) .with_updates_by_pk(|row| &row.scheduled_id); + diff.game_item_enchantment = cache + .apply_diff_to_table::( + "game_item_enchantment", + &self.game_item_enchantment, + ) + .with_updates_by_pk(|row| &row.id); diff.game_item_instance = cache .apply_diff_to_table::("game_item_instance", &self.game_item_instance) .with_updates_by_pk(|row| &row.guid); + diff.game_item_property_weight = cache + .apply_diff_to_table::( + "game_item_property_weight", + &self.game_item_property_weight, + ) + .with_updates_by_pk(|row| &row.id); + diff.game_item_random_property = cache + .apply_diff_to_table::( + "game_item_random_property", + &self.game_item_random_property, + ) + .with_updates_by_pk(|row| &row.property_id); diff.game_item_template = cache .apply_diff_to_table::("game_item_template", &self.game_item_template) .with_updates_by_pk(|row| &row.entry); @@ -9340,9 +9397,18 @@ impl __sdk::DbUpdate for DbUpdate { "game_instance_reaper_schedule" => db_update .game_instance_reaper_schedule .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_item_enchantment" => db_update + .game_item_enchantment + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_item_instance" => db_update .game_item_instance .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_item_property_weight" => db_update + .game_item_property_weight + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_item_random_property" => db_update + .game_item_random_property + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_item_template" => db_update .game_item_template .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -10073,9 +10139,18 @@ impl __sdk::DbUpdate for DbUpdate { "game_instance_reaper_schedule" => db_update .game_instance_reaper_schedule .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_item_enchantment" => db_update + .game_item_enchantment + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_item_instance" => db_update .game_item_instance .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_item_property_weight" => db_update + .game_item_property_weight + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_item_random_property" => db_update + .game_item_random_property + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_item_template" => db_update .game_item_template .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -10547,7 +10622,10 @@ pub struct AppliedDiff<'r> { game_instance: __sdk::TableAppliedDiff<'r, GameInstance>, game_instance_binding: __sdk::TableAppliedDiff<'r, GameInstanceBinding>, game_instance_reaper_schedule: __sdk::TableAppliedDiff<'r, InstanceReaperSchedule>, + game_item_enchantment: __sdk::TableAppliedDiff<'r, ItemEnchantment>, game_item_instance: __sdk::TableAppliedDiff<'r, ItemInstance>, + game_item_property_weight: __sdk::TableAppliedDiff<'r, ItemPropertyWeight>, + game_item_random_property: __sdk::TableAppliedDiff<'r, ItemRandomProperty>, game_item_template: __sdk::TableAppliedDiff<'r, ItemTemplate>, game_level_stats: __sdk::TableAppliedDiff<'r, LevelStats>, game_levelup_event: __sdk::TableAppliedDiff<'r, LevelupEvent>, @@ -11296,11 +11374,26 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_instance_reaper_schedule, event, ); + callbacks.invoke_table_row_callbacks::( + "game_item_enchantment", + &self.game_item_enchantment, + event, + ); callbacks.invoke_table_row_callbacks::( "game_item_instance", &self.game_item_instance, event, ); + callbacks.invoke_table_row_callbacks::( + "game_item_property_weight", + &self.game_item_property_weight, + event, + ); + callbacks.invoke_table_row_callbacks::( + "game_item_random_property", + &self.game_item_random_property, + event, + ); callbacks.invoke_table_row_callbacks::( "game_item_template", &self.game_item_template, @@ -12600,7 +12693,10 @@ impl __sdk::SpacetimeModule for RemoteModule { game_instance_table::register_table(client_cache); game_instance_binding_table::register_table(client_cache); game_instance_reaper_schedule_table::register_table(client_cache); + game_item_enchantment_table::register_table(client_cache); game_item_instance_table::register_table(client_cache); + game_item_property_weight_table::register_table(client_cache); + game_item_random_property_table::register_table(client_cache); game_item_template_table::register_table(client_cache); game_level_stats_table::register_table(client_cache); game_levelup_event_table::register_table(client_cache); @@ -12842,7 +12938,10 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_instance", "game_instance_binding", "game_instance_reaper_schedule", + "game_item_enchantment", "game_item_instance", + "game_item_property_weight", + "game_item_random_property", "game_item_template", "game_level_stats", "game_levelup_event", diff --git a/gateway/src/stdb/bindings/realm_auction_commit_listing_reducer.rs b/gateway/src/stdb/bindings/realm_auction_commit_listing_reducer.rs index 9c82c81ea..646d55ba2 100644 --- a/gateway/src/stdb/bindings/realm_auction_commit_listing_reducer.rs +++ b/gateway/src/stdb/bindings/realm_auction_commit_listing_reducer.rs @@ -15,6 +15,7 @@ pub(super) struct RealmAuctionCommitListingArgs { pub item_durability: u32, pub item_enchant_id: u32, pub item_soulbound: bool, + pub random_property_id: u32, pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, @@ -37,6 +38,7 @@ impl From for super::Reducer { item_durability: args.item_durability, item_enchant_id: args.item_enchant_id, item_soulbound: args.item_soulbound, + random_property_id: args.random_property_id, house: args.house, deposit_rate: args.deposit_rate, consignment_rate: args.consignment_rate, @@ -75,6 +77,7 @@ pub trait realm_auction_commit_listing { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -94,6 +97,7 @@ pub trait realm_auction_commit_listing { item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, @@ -123,6 +127,7 @@ pub trait realm_auction_commit_listing { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -150,6 +155,7 @@ impl realm_auction_commit_listing for super::RemoteReducers { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -174,6 +180,7 @@ impl realm_auction_commit_listing for super::RemoteReducers { item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, diff --git a/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs index b914302aa..fb5f9ed7d 100644 --- a/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs +++ b/gateway/src/stdb/bindings/realm_auction_refund_listing_reducer.rs @@ -15,6 +15,7 @@ pub(super) struct RealmAuctionRefundListingArgs { pub item_durability: u32, pub item_enchant_id: u32, pub item_soulbound: bool, + pub random_property_id: u32, pub house: u32, pub deposit_rate: u32, pub consignment_rate: u32, @@ -37,6 +38,7 @@ impl From for super::Reducer { item_durability: args.item_durability, item_enchant_id: args.item_enchant_id, item_soulbound: args.item_soulbound, + random_property_id: args.random_property_id, house: args.house, deposit_rate: args.deposit_rate, consignment_rate: args.consignment_rate, @@ -75,6 +77,7 @@ pub trait realm_auction_refund_listing { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -94,6 +97,7 @@ pub trait realm_auction_refund_listing { item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, @@ -123,6 +127,7 @@ pub trait realm_auction_refund_listing { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -150,6 +155,7 @@ impl realm_auction_refund_listing for super::RemoteReducers { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, house: u32, deposit_rate: u32, consignment_rate: u32, @@ -174,6 +180,7 @@ impl realm_auction_refund_listing for super::RemoteReducers { item_durability, item_enchant_id, item_soulbound, + random_property_id, house, deposit_rate, consignment_rate, diff --git a/gateway/src/stdb/bindings/realm_loot_op_reducer.rs b/gateway/src/stdb/bindings/realm_loot_op_reducer.rs index 88e57e893..57743b810 100644 --- a/gateway/src/stdb/bindings/realm_loot_op_reducer.rs +++ b/gateway/src/stdb/bindings/realm_loot_op_reducer.rs @@ -15,6 +15,7 @@ pub(super) struct RealmLootOpArgs { pub vote: u8, pub deadline_micros: i64, pub recipients: Vec, + pub random_property_id: u32, } impl From for super::Reducer { @@ -28,6 +29,7 @@ impl From for super::Reducer { vote: args.vote, deadline_micros: args.deadline_micros, recipients: args.recipients, + random_property_id: args.random_property_id, } } } @@ -57,6 +59,7 @@ pub trait realm_loot_op { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, ) -> __sdk::Result<()> { self.realm_loot_op_then( op, @@ -67,6 +70,7 @@ pub trait realm_loot_op { vote, deadline_micros, recipients, + random_property_id, |_, _| {}, ) } @@ -87,6 +91,7 @@ pub trait realm_loot_op { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -105,6 +110,7 @@ impl realm_loot_op for super::RemoteReducers { vote: u8, deadline_micros: i64, recipients: Vec, + random_property_id: u32, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -120,6 +126,7 @@ impl realm_loot_op for super::RemoteReducers { vote, deadline_micros, recipients, + random_property_id, }, callback, ) diff --git a/gateway/src/stdb/bindings/realm_mail_commit_reducer.rs b/gateway/src/stdb/bindings/realm_mail_commit_reducer.rs index 24ee77aeb..1939d2f7a 100644 --- a/gateway/src/stdb/bindings/realm_mail_commit_reducer.rs +++ b/gateway/src/stdb/bindings/realm_mail_commit_reducer.rs @@ -18,6 +18,7 @@ pub(super) struct RealmMailCommitArgs { pub item_durability: u32, pub item_enchant_id: u32, pub item_soulbound: bool, + pub random_property_id: u32, pub cod: u32, pub cod_mail_id: u64, } @@ -36,6 +37,7 @@ impl From for super::Reducer { item_durability: args.item_durability, item_enchant_id: args.item_enchant_id, item_soulbound: args.item_soulbound, + random_property_id: args.random_property_id, cod: args.cod, cod_mail_id: args.cod_mail_id, } @@ -70,6 +72,7 @@ pub trait realm_mail_commit { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, cod: u32, cod_mail_id: u64, ) -> __sdk::Result<()> { @@ -85,6 +88,7 @@ pub trait realm_mail_commit { item_durability, item_enchant_id, item_soulbound, + random_property_id, cod, cod_mail_id, |_, _| {}, @@ -110,6 +114,7 @@ pub trait realm_mail_commit { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, cod: u32, cod_mail_id: u64, @@ -133,6 +138,7 @@ impl realm_mail_commit for super::RemoteReducers { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, cod: u32, cod_mail_id: u64, @@ -153,6 +159,7 @@ impl realm_mail_commit for super::RemoteReducers { item_durability, item_enchant_id, item_soulbound, + random_property_id, cod, cod_mail_id, }, diff --git a/gateway/src/stdb/bindings/realm_mail_item_payout_reducer.rs b/gateway/src/stdb/bindings/realm_mail_item_payout_reducer.rs index da536f688..ca1bb947c 100644 --- a/gateway/src/stdb/bindings/realm_mail_item_payout_reducer.rs +++ b/gateway/src/stdb/bindings/realm_mail_item_payout_reducer.rs @@ -15,6 +15,7 @@ pub(super) struct RealmMailItemPayoutArgs { pub item_durability: u32, pub item_enchant_id: u32, pub item_soulbound: bool, + pub random_property_id: u32, } impl From for super::Reducer { @@ -28,6 +29,7 @@ impl From for super::Reducer { item_durability: args.item_durability, item_enchant_id: args.item_enchant_id, item_soulbound: args.item_soulbound, + random_property_id: args.random_property_id, } } } @@ -57,6 +59,7 @@ pub trait realm_mail_item_payout { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, ) -> __sdk::Result<()> { self.realm_mail_item_payout_then( escrow_id, @@ -67,6 +70,7 @@ pub trait realm_mail_item_payout { item_durability, item_enchant_id, item_soulbound, + random_property_id, |_, _| {}, ) } @@ -87,6 +91,7 @@ pub trait realm_mail_item_payout { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -105,6 +110,7 @@ impl realm_mail_item_payout for super::RemoteReducers { item_durability: u32, item_enchant_id: u32, item_soulbound: bool, + random_property_id: u32, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -120,6 +126,7 @@ impl realm_mail_item_payout for super::RemoteReducers { item_durability, item_enchant_id, item_soulbound, + random_property_id, }, callback, ) diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 7cf93c108..314a92260 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -3338,7 +3338,7 @@ fn append_final_item_slots( } for (slot, (guid, _, _)) in slots { if let Some((bag_slot, slot_in_bag)) = bag_content_parts(slot) { - if let Some((bag_guid, _)) = item_in_slot(bag_slot) { + if let Some((bag_guid, _, _)) = item_in_slot(bag_slot) { let (opcode, body) = codec::build_container_slot_values(bag_guid, slot_in_bag, guid); out.push(Outbound::Raw { opcode, body }); @@ -5296,7 +5296,7 @@ mod tests { let view = entity_view(entity, 0); let out = - peer_create_outbound(&view, &[(0, 700, 1337)], &index.on_target(0, view.guid)) + peer_create_outbound(&view, &[(0, 700, 1337, 0)], &index.on_target(0, view.guid)) .unwrap(); let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::Raw { opcode, body }] = out.as_slice() @@ -5421,7 +5421,7 @@ mod tests { committed .iter() .find(|item| item.0 == slot) - .map(|item| (item.1, item.2)) + .map(|item| (item.1, item.2, 0)) }; let mut outbound = Vec::new(); @@ -5476,7 +5476,7 @@ mod tests { let mut outbound = Vec::new(); append_final_item_slots(7, 23, 0, &mut outbound, |slot| { - (slot == 0).then_some((202, 1337)) + (slot == 0).then_some((202, 1337, 0)) }); let mut equipment = Vec::new(); @@ -5506,7 +5506,7 @@ mod tests { let mut outbound = Vec::new(); append_final_item_slots(7, 23, 24, &mut outbound, |slot| { - (slot == 24).then_some((202, 1337)) + (slot == 24).then_some((202, 1337, 0)) }); let mut source = Vec::new(); diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index f05bba70d..21cc7f12a 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -831,7 +831,7 @@ mod tests { #[test] fn chest_use_precedes_generated_loot_and_opens_the_shared_window() { let mut store = InMemoryLootWindow::default(); - store.items_by_viewer.insert(42, vec![(4, 117, 2, 321)]); + store.items_by_viewer.insert(42, vec![(4, 117, 2, 321, 0)]); let current_state = OpenLootState { target_guid: Some(11), }; @@ -1504,7 +1504,7 @@ mod tests { money: 25, ..Default::default() }; - store.items_by_viewer.insert(42, vec![(3, 2589, 5, 200)]); + store.items_by_viewer.insert(42, vec![(3, 2589, 5, 200, 0)]); let outcome = dispatch_loot_window( &store, @@ -1552,8 +1552,8 @@ mod tests { #[test] fn two_viewers_of_one_creature_receive_their_own_visible_items() { let mut store = InMemoryLootWindow::default(); - store.items_by_viewer.insert(42, vec![(0, 6948, 1, 100)]); - store.items_by_viewer.insert(43, vec![(2, 2589, 5, 200)]); + store.items_by_viewer.insert(42, vec![(0, 6948, 1, 100, 0)]); + store.items_by_viewer.insert(43, vec![(2, 2589, 5, 200, 0)]); let first = dispatch_loot_window( &store, diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 16f463c60..15467851c 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -888,7 +888,7 @@ mod tests { #[test] fn selling_an_item_resolves_its_guid_to_the_durable_slot_and_returns_the_buyback_view() { - let ring = vec![(2589, 1, 120)]; + let ring = vec![(2589, 1, 120, 0)]; let actions = InMemoryVendorActions { item_slots: vec![(ITEM, 30)], ring: ring.clone(), diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index d11abdde4..0047355b8 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -407,7 +407,7 @@ struct InMemoryStore { /// What `talent_grant_spell` returns (0 = passive talent → no SMSG_LEARNED_SPELL push). talent_grant: u32, /// What `talent_pane_sync` returns: (teach rank-spell, superseded prev, points remaining). - talent_pane: (u32, u32, u32, u32), + talent_pane: (u32, u32, u32), /// What `superseded_old_rank` returns for a trainer buy — the known previous rank a /// non-stacking chain's new rank replaces. `None` (derive-Default) mirrors "no known prior /// rank" -> a trainer buy pushes plain SMSG_LEARNED_SPELL. @@ -1912,7 +1912,7 @@ impl WorldStore for InMemoryStore { ) -> Result<()> { Ok(()) } - fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32, u32) { + fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { self.talent_pane } fn talent_points_spent(&self, _character_guid: u64) -> u32 { @@ -2621,7 +2621,7 @@ impl WorldStore for InMemoryStore { vote: u8, deadline_micros: i64, recipients: Vec, - random_property_id: u32, + _random_property_id: u32, ) -> Result<()> { self.rec("realm_loot_op"); self.realm_loot_ops.lock().unwrap().push(( @@ -7111,7 +7111,8 @@ fn questgiver_gameobject_bypasses_the_chest_lifecycle() { s.gameobject_type = Some(lyracore_shared::constants::go_type::QUESTGIVER); s.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; s.quest_details = vec![detail_view(1234, "A Threat Within")]; - s.corpse_loot_by_viewer.insert(1, vec![(0, 2589, 1, 200)]); + s.corpse_loot_by_viewer + .insert(1, vec![(0, 2589, 1, 200, 0)]); let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -7161,7 +7162,7 @@ fn non_chest_gameobject_preserves_the_general_use_path() { fn chest_dispatch_opens_the_shared_window_and_tracks_its_target() { let mut s = quest_store(); s.gameobject_type = Some(lyracore_shared::constants::go_type::CHEST); - s.corpse_loot_by_viewer.insert(1, vec![(4, 117, 2, 321)]); + s.corpse_loot_by_viewer.insert(1, vec![(4, 117, 2, 321, 0)]); let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -7766,7 +7767,7 @@ fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { // entry, then the raw descriptor update. (An EMPTY ring emits nothing — every other login test // reads the login sequence and then EOF, which is that case.) let store = std::sync::Arc::new(InMemoryStore { - buyback_ring: vec![(2589, 5, 120), (4540, 1, 30)], + buyback_ring: vec![(2589, 5, 120, 0), (4540, 1, 30, 0)], ..quest_store() }); let (mut client, _c_enc, mut c_dec, server) = enter_world(store.clone(), 1); diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 373e89374..0207473e0 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -464,6 +464,7 @@ parity_test!(parity_game_system_message_event, "game_system_message_event", lyra // realm-core's votes back — a drifted column here breaks that relay silently, not a client packet. parity_test!(parity_game_loot_roll, "game_loot_roll", lyracore_module::LootRoll, bindings::loot_roll_type::LootRoll, { id, corpse_guid, slot, item_entry, deadline_micros, resolved, + random_property_id, }); parity_test!(parity_game_loot_roll_vote, "game_loot_roll_vote", lyracore_module::LootRollVote, bindings::loot_roll_vote_type::LootRollVote, { id, roll_id, voter_guid, voted, vote, rolled, @@ -567,6 +568,7 @@ parity_test!(parity_game_item_template, "game_item_template", lyracore_module::I spelltrigger_5, required_skill, required_skill_rank, required_reputation_faction, required_reputation_rank, max_count, item_flags, page_text, start_quest, bag_family, buy_count, food_type, allowed_class, allowed_race, + random_property, }); parity_test!(parity_game_spell, "game_spell", lyracore_module::Spell, bindings::spell_type::Spell, { spell_id, name, power_type, cost, cast_time_ms, gcd_ms, cooldown_ms, range_yd, duration_ms, @@ -586,10 +588,12 @@ parity_test!(parity_game_spell_effect, "game_spell_effect", lyracore_module::Spe parity_test!(parity_game_item_instance, "game_item_instance", lyracore_module::ItemInstance, bindings::item_instance_type::ItemInstance, { guid, entry, owner_identity, owner_guid, slot, stack_count, durability, created_at, enchant_id, soulbound, + random_property_id, }); parity_test!(parity_game_corpse_loot, "game_corpse_loot", lyracore_module::CorpseLoot, bindings::corpse_loot_type::CorpseLoot, { id, corpse_guid, slot, item_entry, count, quest_only, reserved_for, designated_looter_guid, master_only, withheld, + random_property_id, }); parity_test!(parity_game_npc_vendor, "game_npc_vendor", lyracore_module::NpcVendor, bindings::npc_vendor_type::NpcVendor, { id, creature_entry, item_entry, slot, max_count, @@ -689,6 +693,7 @@ parity_test!(parity_game_creature_spline, "game_creature_spline", lyracore_modul }); parity_test!(parity_game_character_buyback, "game_character_buyback", lyracore_module::BuybackEntry, bindings::buyback_entry_type::BuybackEntry, { id, player_guid, item_entry, stack_count, price, soulbound, + random_property_id, }); parity_test!(parity_game_gameobject_template, "game_gameobject_template", lyracore_module::GameObjectTemplate, bindings::game_object_template_type::GameObjectTemplate, { entry, type_id, display_id, name, data_0, data_1, gather_skill_line, respawn_secs, @@ -764,16 +769,19 @@ parity_test!(parity_game_character_contact, "game_character_contact", lyracore_m parity_test!(parity_game_mail, "game_mail", lyracore_module::Mail, bindings::mail_type::Mail, { id, recipient_guid, sender_guid, subject, body, item_entry, item_stack_count, item_durability, item_enchant_id, item_soulbound, money, cod, was_read, created_at, + random_property_id, }); parity_test!(parity_game_mail_escrow, "game_mail_escrow", lyracore_module::MailEscrow, bindings::mail_escrow_type::MailEscrow, { escrow_id, sender_guid, recipient_guid, subject, body, money, postage, created_micros, delivered, payout, mail_id, item_entry, item_stack_count, item_durability, item_enchant_id, item_soulbound, cod, + random_property_id, }); parity_test!(parity_game_auction, "game_auction", lyracore_module::Auction, bindings::auction_type::Auction, { id, listing_operation_id, house, owner_guid, item_guid, item_entry, item_stack_count, item_durability, item_enchant_id, item_soulbound, start_bid, buyout, highest_bidder_guid, highest_bid, deposit, created_at, expires_at, revision, deposit_rate, consignment_rate, + random_property_id, }); parity_test!(parity_game_auction_bid_decision, "game_auction_bid_decision", lyracore_module::AuctionBidDecision, bindings::auction_bid_decision_type::AuctionBidDecision, { operation_id, bidder_guid, auction_id, offer, outcome, revision, result_bidder_guid, @@ -787,11 +795,13 @@ parity_test!(parity_game_auction_hold, "game_auction_hold", lyracore_module::Auc operation_id, seller_guid, item_guid, item_entry, item_stack_count, item_durability, item_enchant_id, item_soulbound, start_bid, buyout, duration_minutes, deposit, created_micros, expires_micros, house, deposit_rate, consignment_rate, + random_property_id, }); parity_test!(parity_game_auction_operation_receipt, "game_auction_operation_receipt", lyracore_module::AuctionOperationReceipt, bindings::auction_operation_receipt_type::AuctionOperationReceipt, { operation_id, auction_id, actor_guid, item_guid, item_entry, item_stack_count, item_durability, item_enchant_id, item_soulbound, start_bid, buyout, duration_minutes, deposit, created_micros, expires_micros, house, deposit_rate, consignment_rate, + random_property_id, }); parity_test!(parity_game_auction_house, "game_auction_house", lyracore_module::AuctionHouseDefinition, bindings::auction_house_definition_type::AuctionHouseDefinition, { id, faction, deposit_rate, consignment_rate, name, @@ -1003,3 +1013,13 @@ fn every_subscribed_table_in_connection_rs_has_a_parity_manifest_entry() { break live BSATN decode silently. See docs/agent-playbook.md failure-mode §1." ); } + +parity_test!(parity_game_item_random_property, "game_item_random_property", lyracore_module::ItemRandomProperty, bindings::item_random_property_type::ItemRandomProperty, { + property_id, enchant_id_1, enchant_id_2, enchant_id_3, suffix, +}); +parity_test!(parity_game_item_enchantment, "game_item_enchantment", lyracore_module::ItemEnchantment, bindings::item_enchantment_type::ItemEnchantment, { + id, enchant_id, effect_index, kind, amount, spell_id, school_mask, +}); +parity_test!(parity_game_item_property_weight, "game_item_property_weight", lyracore_module::ItemPropertyWeight, bindings::item_property_weight_type::ItemPropertyWeight, { + id, pool_id, property_id, weight, +}); diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index 83b1c467e..3d750c474 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -194,12 +194,11 @@ fn catalogue_rows_sql( continue; } let argument = enchant.effect_arg[slot]; - if matches!(source_kind, 1 | 3) && !spells.contains_key(&(argument as u32)) { - bail!("enchantment {enchant_id} slot {slot} references missing spell {argument}"); - } if source_kind == 3 { - effects.extend(spell_effects(slot as u8, spells[&(argument as u32)])?); - continue; + if let Some(spell) = spells.get(&(argument as u32)) { + effects.extend(spell_effects(slot as u8, spell)?); + continue; + } } let kind = match source_kind { 2 => kind::WEAPON_DAMAGE, @@ -220,7 +219,11 @@ fn catalogue_rows_sql( index: slot as u8 * 64, kind, amount: enchant.effect_points_min[slot], - spell_id: if source_kind == 1 { argument as u32 } else { 0 }, + spell_id: if matches!(source_kind, 1 | 3) { + argument as u32 + } else { + 0 + }, school_mask: 0, }); } @@ -314,15 +317,24 @@ fn decimal_weight(value: &str) -> Result { Ok(weight) } +pub(crate) fn template_pool(row: &[String]) -> Result { + field(row, crate::it::RANDOM_PROPERTY) + .parse::() + .with_context(|| { + format!( + "item {} has an invalid Property Pool ID", + field(row, crate::it::ENTRY) + ) + }) +} + pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> { let raw = parse_table(dump, "item_enchantment_template"); let required: BTreeSet = parse_table(dump, "item_template") .iter() - .map(|row| { - field(row, crate::it::RANDOM_PROPERTY) - .parse::() - .unwrap_or(0) - }) + .map(|row| template_pool(row)) + .collect::>>()? + .into_iter() .filter(|id| *id != 0) .collect(); if raw.is_empty() && required.is_empty() { @@ -415,6 +427,17 @@ mod tests { } } + #[test] + fn absent_linked_spells_retain_the_source_reference_as_unknown() { + let (properties, mut enchantments, spells) = catalogues(); + enchantments.rows[0].enchantment_type = [1, 3, 0]; + enchantments.rows[0].effect_arg = [509_0098, 509_0099, 0]; + let (sql, _) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); + let sql = sql.join("\n"); + assert!(sql.contains("(1303040512,5090002,0,0,7,5090098,0)")); + assert!(sql.contains("(1303040576,5090002,64,0,9,5090099,0)")); + } + #[test] fn empty_or_inconsistent_catalogues_produce_no_import_statements() { for missing in 0..3 { diff --git a/importer/src/main.rs b/importer/src/main.rs index e1477c76b..26cc666f1 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -3099,7 +3099,7 @@ fn build_items_and_loot( creature_loot_ids: &std::collections::HashMap, vendor_entries: &std::collections::HashSet, extra_item_entries: &std::collections::HashSet, -) -> (Vec, Vec, Vec) { +) -> Result<(Vec, Vec, Vec)> { use std::collections::HashSet; // 0+1) Parse the LootId-keyed creature drops (reference-pool expansion + direct rows). Shared with @@ -3356,7 +3356,7 @@ fn build_items_and_loot( delay = field(&row, it::DELAY), armor = field(&row, it::ARMOR), block = field(&row, it::BLOCK), - random_property = field(&row, it::RANDOM_PROPERTY).parse::().unwrap_or(0), + random_property = item_property::template_pool(&row)?, allowed_class = allowed_class, allowed_race = allowed_race, )); @@ -3384,7 +3384,7 @@ fn build_items_and_loot( ); } - (item_rows, loot_rows, vendor_rows) + Ok((item_rows, loot_rows, vendor_rows)) } /// Append chunked `INSERT INTO {table} ({cols}) VALUES (...)` statements for `rows` — bounded BOTH by @@ -3819,7 +3819,7 @@ fn build_dump_plan( .copied() .collect(); let (item_rows, loot_rows, vendor_rows) = - build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries); + build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries)?; eprintln!( "mapped: {} item_templates, {} creature_loot rows, {} npc_vendor rows ({} creatures with loot)", item_rows.len(), loot_rows.len(), vendor_rows.len(), creature_loot_ids.len() @@ -6200,7 +6200,7 @@ mod tests { let dump = format!("x INSERT INTO `item_template` VALUES ({tuple}); y"); let (item_rows, _loot, _vendor) = - build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()); + build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()).unwrap(); assert_eq!(item_rows.len(), 1); assert_eq!( item_rows[0], @@ -6239,7 +6239,7 @@ mod tests { let dump = format!("x INSERT INTO `item_template` VALUES ({tuple}); y"); let (item_rows, _loot, _vendor) = - build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()); + build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()).unwrap(); assert_eq!(item_rows.len(), 1); assert_eq!( item_rows[0], @@ -6260,7 +6260,7 @@ mod tests { let tuple = cols.join(",").replacen("__NAME__", "'Opaque Mask Item'", 1); let dump = format!("x INSERT INTO `item_template` VALUES ({tuple}); y"); let (item_rows, _loot, _vendor) = - build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()); + build_items_and_loot(&dump, &HashMap::new(), &HashSet::new(), &HashSet::new()).unwrap(); assert_eq!(item_rows.len(), 1); assert!( diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index f05493a5a..12520ca12 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -231,12 +231,11 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { e.max_power // unchanged for rage/energy pools }; - // --- effective Spirit into the FIELD (regen + the char sheet both read e.spirit; unlike STR/AGI which - // combat re-derives at read time, Spirit's only consumers read the field, so folding it here is safe — - // no double-count). base (curve) + flat A_MOD_STAT(SPI) auras, then × the A_MOD_STAT_PCT(SPI) multiplier - // (The Human Spirit +5%). Re-derives base from the curve each call, so it's idempotent on relog. + // Regen and the character sheet read effective Spirit from the entity field. + // Rebuild from the base curve, gear and auras before applying percent bonuses. let base_spirit = crate::stats::base_attributes_for(ctx, race, class, e.level).4 as i32; - let spi_flat = stat_bonus(ctx, unit_guid, STAT_SPI); + let spi_flat = stat_bonus(ctx, unit_guid, STAT_SPI) + + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Spirit); let spi_pct = stat_pct_bonus(ctx, unit_guid, STAT_SPI); let new_spirit = (((base_spirit + spi_flat) * (100 + spi_pct)) / 100).max(0) as u32; diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs index 5eec94640..9583d3206 100644 --- a/module/tests/random_properties.rs +++ b/module/tests/random_properties.rs @@ -14,7 +14,7 @@ fn fixture(name: &str) -> Standalone { shard.assert_call("debug_spawn_player_entity", &["1"]); shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); shard.assert_sql("INSERT INTO game_item_random_property (property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix) VALUES (5090101,5090103,0,0,'of the Fixture'),(5090102,0,0,0,'of the Other Fixture')"); - shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,3,7,0,0)"); + shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,3,7,0,0),(1303066369,5090103,1,5,9,0,0)"); shard.assert_sql("INSERT INTO game_item_property_weight (id,pool_id,property_id,weight) VALUES (5090100,5090100,5090101,10000)"); shard.assert_sql(&format!("UPDATE game_item_template SET random_property = {POOL}, stat_stamina = 0, bonding = 0 WHERE entry = {ITEM}")); shard @@ -32,6 +32,16 @@ fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() let shard = fixture("property-loot"); shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false)")); shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = + shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false"); + shard.assert_call("debug_apply_damage", &[&wolf[0]["guid"], "100", "1"]); + assert_eq!( + shard + .query_rows("SELECT character_guid FROM game_creature_quest_tap") + .len(), + 1, + "damage must acquire the Loot Tag" + ); shard.assert_call("debug_kill_nearest", &["1", "51000"]); let loot = shard.query_rows(&format!( "SELECT * FROM game_corpse_loot WHERE item_entry = {ITEM}" @@ -43,11 +53,7 @@ fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() ); shard.assert_call( "debug_take_loot", - &[ - "1", - &format!("\"{}\"", loot[0]["corpse_guid"]), - &loot[0]["slot"], - ], + &["1", &loot[0]["corpse_guid"], &loot[0]["slot"]], ); let item = shard.query_rows(&format!( "SELECT * FROM game_item_instance WHERE owner_guid = 1 AND entry = {ITEM}" @@ -55,12 +61,21 @@ fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() assert_eq!(item.len(), 1); assert_eq!(item[0]["random_property_id"], PROPERTY); let base = max_health(&shard); + let base_spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1") + [0]["spirit"] + .parse() + .unwrap(); shard.assert_call("debug_equip_item", &["1", &item[0]["slot"]]); assert_eq!( max_health(&shard), base + 70, "seven Stamina above the base curve adds 70 health" ); + let spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1")[0] + ["spirit"] + .parse() + .unwrap(); + assert_eq!(spirit, base_spirit + 9); shard.assert_sql( "UPDATE game_item_instance SET enchant_id = 7748 WHERE owner_guid = 1 AND slot = 15", ); @@ -81,6 +96,11 @@ fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() base, "broken items grant neither overlay" ); + let spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1")[0] + ["spirit"] + .parse() + .unwrap(); + assert_eq!(spirit, base_spirit); } fn create_spell(shard: &Standalone) { @@ -156,3 +176,62 @@ fn splitting_and_moving_stacks_never_mix_properties() { .iter() .any(|item| item["random_property_id"] == "5090102" && item["stack_count"] == "4")); } + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn mail_escrow_and_replayed_payout_preserve_both_plain_and_random_items() { + let shard = fixture("property-mail"); + for (escrow_id, property) in [(5090110, PROPERTY), (5090120, "0")] { + let send = escrow_id.to_string(); + shard.assert_call( + "realm_mail_commit", + &[ + &send, + "2", + "1", + "\"Fixture\"", + "\"\"", + "0", + ITEM, + "1", + "42", + "7748", + "false", + property, + "0", + "0", + ], + ); + let mails = shard.query_rows("SELECT id,random_property_id FROM game_mail WHERE recipient_guid = 1 AND item_entry = 5090050"); + assert_eq!(mails.len(), 1); + assert_eq!(mails[0]["random_property_id"], property); + let payout = (escrow_id + 1).to_string(); + shard.assert_call( + "realm_mail_take_item_fence", + &[&payout, "1", &mails[0]["id"], ITEM], + ); + let fenced = shard.query_rows(&format!( + "SELECT random_property_id FROM game_mail_escrow WHERE escrow_id = {payout}" + )); + assert_eq!(fenced[0]["random_property_id"], property); + let args = [ + &payout, + "1", + &mails[0]["id"], + ITEM, + "1", + "42", + "7748", + "false", + property, + ]; + shard.assert_call("realm_mail_item_payout", &args); + shard.assert_call("realm_mail_item_payout", &args); + let items = shard.query_rows("SELECT random_property_id,enchant_id,durability FROM game_item_instance WHERE owner_guid = 1"); + assert_eq!(items.len(), 1, "payout replay must not duplicate the item"); + assert_eq!(items[0]["random_property_id"], property); + assert_eq!(items[0]["enchant_id"], "7748"); + assert_eq!(items[0]["durability"], "42"); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + } +} From 7a2e8caa4f4c69f7ab96018e29af62cf3fa6b9da Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:24:18 +0000 Subject: [PATCH 3/8] fix(items): verify property movement and bound stat projections --- gateway/src/codec/loot.rs | 9 +- gateway/src/codec/tests.rs | 23 +---- gateway/src/stdb/armor.rs | 80 ++++++++++++---- gateway/src/stdb/subscriptions.rs | 50 ++++------ gateway/src/world/handlers/vendor.rs | 17 ++-- importer/src/item_property.rs | 22 ++--- importer/src/main.rs | 1 + module/src/spell/effects.rs | 20 ++-- module/tests/random_properties.rs | 134 +++++++++++++++++++++++++++ 9 files changed, 250 insertions(+), 106 deletions(-) diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 9e08bf189..52f7892f3 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -10,7 +10,7 @@ use wow_world_messages::vanilla::{ /// `SMSG_LOOT_RESPONSE` opcode (vanilla 5875). Pinned against gtker in the byte-match test below. const SMSG_LOOT_RESPONSE_OPCODE: u16 = 0x0160; -/// One lootable item for the loot window: `(slot, item_id, count, display_id)`. `display_id` is the +/// One lootable item for the loot window: `(slot, item_id, count, display_id, random_property_id)`. `display_id` is the /// item's `ItemDisplayInfo` id (the gateway joins it from `game_item_template`); the client also /// queries the item (`CMSG_ITEM_QUERY_SINGLE`) for the name/tooltip (slice-1 path). pub type LootItemView = (u8, u32, u32, u32, u32); @@ -120,10 +120,8 @@ pub(crate) fn wire_roll_number(vote: u8, rolled: u8) -> u8 { } } -/// Build `SMSG_LOOT_ROLL` — one member's vote landing (relayed to every eligible member so live -/// votes/roll numbers are visible party-wide, matching vanilla). `auto_pass` from the module's event -/// payload is folded into the wire `roll_number` (see `wire_roll_number`) rather than carried as a -/// separate field — the vanilla message has none. +/// Project a member's vote to every eligible member. Vanilla represents both manual and +/// automatic passes with the same `roll_number` sentinel. pub fn build_loot_roll( corpse_guid: u64, loot_slot: u8, @@ -131,7 +129,6 @@ pub fn build_loot_roll( item_entry: u32, rolled: u8, vote: u8, - _auto_pass: bool, random_property_id: u32, ) -> SMSG_LOOT_ROLL { SMSG_LOOT_ROLL { diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index c71380608..c42d1de4c 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -483,33 +483,16 @@ fn wire_roll_number_signals_pass_as_128_and_passes_a_real_roll_through() { #[test] fn loot_roll_carries_the_voter_item_and_vote() { - let m = build_loot_roll( - 0xF130_0000_0000_0007, - 3, - 55, - 1234, - 87, - vote_kind::NEED, - false, - 0, - ); + let m = build_loot_roll(0xF130_0000_0000_0007, 3, 55, 1234, 87, vote_kind::NEED, 117); assert_eq!(m.creature.guid(), 0xF130_0000_0000_0007); assert_eq!(m.loot_slot, 3); assert_eq!(m.player.guid(), 55); assert_eq!(m.item, 1234); assert_eq!(m.roll_number, 87); + assert_eq!(m.item_random_property_id, 117); assert_eq!(m.vote, RollVote::Need); // A PASS vote's roll_number folds to the >127 sentinel regardless of the stored `rolled` (0). - let pass = build_loot_roll( - 0xF130_0000_0000_0007, - 3, - 55, - 1234, - 0, - vote_kind::PASS, - true, - 0, - ); + let pass = build_loot_roll(0xF130_0000_0000_0007, 3, 55, 1234, 0, vote_kind::PASS, 117); assert_eq!(pass.roll_number, 128); assert_eq!(pass.vote, RollVote::Pass); } diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 5e13d1a0c..8d09145f2 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -4,6 +4,7 @@ use super::bindings::*; use spacetimedb_sdk::Table; +use std::collections::HashMap; /// Aura kind `A_MOD_RESISTANCE` — a direct resistance/armor bonus (e.g. Demon Skin). Mirrors /// `module::spell::taxonomy::A_MOD_RESISTANCE = 0xA1`. @@ -116,17 +117,29 @@ pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { .filter(|a| a.target_guid == guid) .map(|a| aura_armor_contribution(a.eff_kind, a.eff_p0, a.amount, a.stacks) as i64) .sum(); + // The SDK exposes no non-unique enchantment index. Read the catalogue once per projection. + let mut enchantments = HashMap::::new(); + for effect in db.game_item_enchantment().iter().filter(|effect| { + effect.enchant_id != 0 && effect.kind == lyracore_shared::item_property::ARMOR + }) { + let total = enchantments.entry(effect.enchant_id).or_default(); + *total = total.saturating_add(effect.amount); + } + let enchant_armor = |id| enchantments.get(&id).copied().unwrap_or(0); let templates = db.game_item_template(); - let gear_sum: i64 = db + let gear_sum: i32 = db .game_item_instance() .iter() - .filter(|i| i.owner_guid == guid) + .filter(|i| i.owner_guid == guid && i.slot <= EQUIP_REGION_END) .map(|i| { templates .entry() .find(&i.entry) .map(|t| { - let mut armor = t.stat_armor.saturating_add(enchant_armor(db, i.enchant_id)); + if t.max_durability > 0 && i.durability == 0 { + return 0; + } + let mut armor = t.stat_armor.saturating_add(enchant_armor(i.enchant_id)); if i.random_property_id != 0 { if let Some(property) = db .game_item_random_property() @@ -139,35 +152,64 @@ pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { property.enchant_id_3, ] .into_iter() - .map(|id| enchant_armor(db, id)) + .map(&enchant_armor) .fold(0i32, i32::saturating_add); armor = armor.saturating_add(bonus); } } - gear_armor_contribution(i.slot, armor, t.max_durability, i.durability) as i64 + gear_armor_contribution(i.slot, armor, t.max_durability, i.durability) }) .unwrap_or(0) // a missing template join never poisons the sum (matches the module) }) - .sum(); - (base + aura_sum + gear_sum).max(0) as u32 -} - -fn enchant_armor(db: &RemoteTables, enchant_id: u32) -> i32 { - if enchant_id == 0 { - return 0; - } - db.game_item_enchantment() - .iter() - .filter(|effect| { - effect.enchant_id == enchant_id && effect.kind == lyracore_shared::item_property::ARMOR - }) - .fold(0i32, |sum, effect| sum.saturating_add(effect.amount)) + .fold(0i32, i32::saturating_add); + (base + aura_sum + i64::from(gear_sum)).max(0) as u32 } #[cfg(test)] mod tests { use super::*; + #[test] + #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] + fn armor_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken() { + use crate::accept::BlockingTaskCapacity; + use crate::config::GatewayConfig; + use crate::durable_test_support::{poll_until, Standalone, POLL_TIMEOUT}; + use crate::stdb::Coordinator; + + let mut shard = Standalone::start("property-armor"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + shard.assert_sql("UPDATE game_item_template SET stat_armor = 19 WHERE entry = 5090050"); + shard.assert_sql("INSERT INTO game_item_random_property (property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix) VALUES (5090101,5090103,0,0,'of the Fixture')"); + shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,14,2,0,0),(1303066369,5090103,1,14,3,0,0),(1303066624,5090104,0,14,11,0,0)"); + shard.assert_call("debug_grant_item", &["1", "5090050", "1"]); + shard.assert_sql("UPDATE game_item_instance SET random_property_id = 5090101, enchant_id = 5090104 WHERE owner_guid = 1"); + let base: u32 = shard.query_rows("SELECT armor FROM game_world_entity WHERE guid = 1")[0] + ["armor"] + .parse() + .unwrap(); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: shard.server().into(), + module_name: shard.shard_name().into(), + coordinator_token: Some(shard.owner_token()), + gateway_id: "property-armor-test".into(), + blocking_task_capacity: BlockingTaskCapacity::new(1), + }; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + assert_eq!(coordinator.effective_armor(1), base); + shard.assert_call("debug_equip_item", &["1", "23"]); + assert!(poll_until(POLL_TIMEOUT, || coordinator.effective_armor(1) == base + 35)); + shard.assert_sql("UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1"); + assert!(poll_until(POLL_TIMEOUT, || coordinator.effective_armor(1) == base)); + } + #[test] fn armor_aura_contribution_matches_module_predicate() { // A_MOD_RESISTANCE(0xA1) whose eff_p0 mask includes RESIST_ARMOR(0x01) → amount × max(stacks,1). diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 314a92260..3891dd1f8 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -2159,26 +2159,17 @@ pub(crate) fn group_event_outbound( } }, roll_kind::ROLL_VOTE => match lyracore_shared::loot_roll::decode_vote(&row.payload) { - Some(( - corpse_guid, - slot, - item_entry, - roll_number, - vote, - auto_pass, - random_property_id, - )) => Some(ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new( - codec::build_loot_roll( - corpse_guid, + Some((corpse, slot, entry, number, vote, _auto_pass, property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new(codec::build_loot_roll( + corpse, slot, row.other_guid, - item_entry, - roll_number, + entry, + number, vote, - auto_pass, - random_property_id, - ), - ))), + property_id, + ))), + ), None => { log::warn!( "loot ROLL_VOTE relay: unparseable payload {:?} (event {})", @@ -2189,24 +2180,17 @@ pub(crate) fn group_event_outbound( } }, roll_kind::ROLL_WON => match lyracore_shared::loot_roll::decode_won(&row.payload) { - Some(( - corpse_guid, - slot, - item_entry, - winning_roll, - winning_vote, - random_property_id, - )) => Some(ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new( - codec::build_loot_roll_won( - corpse_guid, + Some((corpse, slot, entry, number, vote, property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new(codec::build_loot_roll_won( + corpse, slot, - item_entry, + entry, row.other_guid, - winning_roll, - winning_vote, - random_property_id, - ), - ))), + number, + vote, + property_id, + ))), + ), None => { log::warn!( "loot ROLL_WON relay: unparseable payload {:?} (event {})", diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 15467851c..00f554e75 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -20,7 +20,7 @@ pub(crate) trait VendorActionStore: Send + Sync { count: u32, ) -> Result<()>; - /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` per entry (≤12). + /// The player's buyback ring, newest-first: `(item_entry, stack_count, price, random_property_id)` per entry (≤12). fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)>; /// Bag slot of the item instance with `item_guid`. Item guids are globally unique, so no @@ -994,7 +994,12 @@ mod tests { codec::build_values_update_raw(PLAYER_GUID, &mask) } - fn expected_create(i: u16, entry: u32, stack_count: u32) -> ServerOpcodeMessage { + fn expected_create( + i: u16, + entry: u32, + stack_count: u32, + random_property_id: u32, + ) -> ServerOpcodeMessage { ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_item_create_object( &codec::ItemInstanceView { guid: 0x4090_0000_0000_0000u64 | u64::from(i), @@ -1005,15 +1010,15 @@ mod tests { durability: 0, max_durability: 0, container_slots: 0, - random_property_id: 0, + random_property_id, }, ))) } fn assert_renders_ring(outbound: &[Outbound], ring: &[(u32, u32, u32, u32)]) { assert_eq!(outbound.len(), ring.len() + 1); - for (i, &(entry, count, _, _)) in ring.iter().enumerate() { - let expected = expected_create(i as u16, entry, count); + for (i, &(entry, count, _, property)) in ring.iter().enumerate() { + let expected = expected_create(i as u16, entry, count, property); assert!( matches!(&outbound[i], Outbound::One(message) if *message == expected), "ring slot {i} did not render its fabricated item" @@ -1028,7 +1033,7 @@ mod tests { #[test] fn the_buyback_view_renders_one_fabricated_item_per_ring_entry_plus_the_descriptor_update() { - let ring = vec![(2589, 5, 120, 0), (4540, 1, 30, 0)]; + let ring = vec![(2589, 5, 120, 117), (4540, 1, 30, 0)]; let actions = InMemoryVendorActions { ring: ring.clone(), ..Default::default() diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index 3d750c474..d41536ed1 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -96,7 +96,7 @@ fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { index: slot * 64 + subeffect as u8 * 8 + expanded as u8, kind, amount, - spell_id: spell.id.id as u32, + spell_id: spell.id.id, school_mask, }); } @@ -106,7 +106,7 @@ fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { index: slot * 64, kind: kind::UNKNOWN, amount: 0, - spell_id: spell.id.id as u32, + spell_id: spell.id.id, school_mask: 0, }); } @@ -134,16 +134,8 @@ fn catalogue_rows_sql( bail!("{name} contains no rows; item catalogues were not changed"); } } - let spells: HashMap<_, _> = spells - .rows() - .iter() - .map(|row| (row.id.id as u32, row)) - .collect(); - let enchant_ids: BTreeSet<_> = enchantments - .rows() - .iter() - .map(|row| row.id.id as u32) - .collect(); + let spells: HashMap<_, _> = spells.rows().iter().map(|row| (row.id.id, row)).collect(); + let enchant_ids: BTreeSet<_> = enchantments.rows().iter().map(|row| row.id.id).collect(); for (id, _, _) in kind::COMPATIBILITY_ENCHANTMENTS { if enchant_ids.contains(&id) { bail!("client enchantment {id} collides with an authored compatibility entry"); @@ -151,7 +143,7 @@ fn catalogue_rows_sql( } let mut property_rows = BTreeMap::new(); for property in properties.rows() { - let id = property.id.id as u32; + let id = property.id.id; if id == 0 { bail!("Random Property ID zero is reserved for plain items"); } @@ -186,7 +178,7 @@ fn catalogue_rows_sql( } let mut enchant_rows = BTreeMap::new(); for enchant in enchantments.rows() { - let enchant_id = enchant.id.id as u32; + let enchant_id = enchant.id.id; let mut effects = Vec::new(); for slot in 0..3 { let source_kind = enchant.enchantment_type[slot]; @@ -365,7 +357,7 @@ pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> } pools.insert(pool); } - for pool in required.difference(&pools) { + if let Some(pool) = required.difference(&pools).next() { bail!("item template references missing Property Pool {pool}"); } eprintln!( diff --git a/importer/src/main.rs b/importer/src/main.rs index 26cc666f1..c7096cfa5 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -17,6 +17,7 @@ //! --dbc client DBC extraction/checks (dbc.rs); add --spells for the //! Spell.dbc importer (spell.rs) or --talents for the //! TalentTab.dbc/Talent.dbc importer (talent.rs) +//! Required with --dump when items reference Random Properties. //! [--packages reapplies every enabled //! Package's Delta claims after the base spell rows are back; //! without --apply it prints the plan and writes nothing — diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 12520ca12..063ea84a5 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -233,11 +233,16 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { // Regen and the character sheet read effective Spirit from the entity field. // Rebuild from the base curve, gear and auras before applying percent bonuses. - let base_spirit = crate::stats::base_attributes_for(ctx, race, class, e.level).4 as i32; - let spi_flat = stat_bonus(ctx, unit_guid, STAT_SPI) - + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Spirit); - let spi_pct = stat_pct_bonus(ctx, unit_guid, STAT_SPI); - let new_spirit = (((base_spirit + spi_flat) * (100 + spi_pct)) / 100).max(0) as u32; + let base_spirit = i128::from(crate::stats::base_attributes_for(ctx, race, class, e.level).4); + let spi_flat = i128::from(stat_bonus(ctx, unit_guid, STAT_SPI)) + + i128::from(crate::items::equipped_stat_bonus( + ctx, + unit_guid, + crate::items::EquipStat::Spirit, + )); + let spi_pct = i128::from(stat_pct_bonus(ctx, unit_guid, STAT_SPI)); + let new_spirit = + (((base_spirit + spi_flat) * (100 + spi_pct)) / 100).clamp(0, i128::from(u32::MAX)) as u32; // No-op write skip: if nothing moved (the no-aura baseline), don't touch the row. if new_max_health == e.max_health && new_max_power == e.max_power && new_spirit == e.spirit { @@ -294,8 +299,9 @@ pub(crate) fn recompute_sheet(ctx: &ReducerContext, unit_guid: u64) { // `recompute_vitals` (called first at every real call site) already folds Spirit's base+aura into // `e.spirit` — re-derive the SAME base curve so the sheet's bonus is `e.spirit - base`, never a // second copy of the aura/pct math. - let base_spirit = crate::stats::base_attributes_for(ctx, race, class, e.level).4 as i32; - let spi_bonus = e.spirit as i32 - base_spirit; + let base_spirit = crate::stats::base_attributes_for(ctx, race, class, e.level).4; + let spi_bonus = (i64::from(e.spirit) - i64::from(base_spirit)) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; let ap_base = crate::combat::melee_attack_power_for(class, eff_str, eff_agi, e.level); let ap_mods = crate::combat::aura_attack_power_bonus(ctx, unit_guid) as i32; diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs index 9583d3206..46cf99788 100644 --- a/module/tests/random_properties.rs +++ b/module/tests/random_properties.rs @@ -31,6 +31,7 @@ fn max_health(shard: &Standalone) -> u32 { fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() { let shard = fixture("property-loot"); shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false)")); + shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); let wolf = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false"); @@ -60,6 +61,7 @@ fn loot_keeps_its_property_after_the_pool_changes_and_equipped_stats_follow_it() )); assert_eq!(item.len(), 1); assert_eq!(item[0]["random_property_id"], PROPERTY); + shard.assert_sql("UPDATE game_world_entity SET stamina = 22 WHERE guid = 1"); let base = max_health(&shard); let base_spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1") [0]["spirit"] @@ -108,6 +110,30 @@ fn create_spell(shard: &Standalone) { shard.assert_sql(&format!("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360400,5090100,0,7,2,0,0.0,0,0,0.0,0,0,0,{ITEM},8,0,0,false)")); } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn large_imported_spirit_contributions_do_not_overflow_equipping() { + let shard = fixture("property-spirit-bound"); + let base: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1")[0] + ["spirit"] + .parse() + .unwrap(); + shard.assert_sql("UPDATE game_item_enchantment SET amount = 2147483647 WHERE id = 1303066369"); + shard.assert_call("debug_grant_item", &["1", ITEM, "1"]); + shard.assert_call("debug_equip_item", &["1", "23"]); + let spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1")[0] + ["spirit"] + .parse() + .unwrap(); + assert_eq!(spirit, base + 2_147_483_647); + shard.assert_call("debug_unequip_item", &["1", "15"]); + let spirit: u32 = shard.query_rows("SELECT spirit FROM game_world_entity WHERE guid = 1")[0] + ["spirit"] + .parse() + .unwrap(); + assert_eq!(spirit, base); +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn creation_is_atomic_even_when_the_spell_handles_a_full_inventory() { @@ -235,3 +261,111 @@ fn mail_escrow_and_replayed_payout_preserve_both_plain_and_random_items() { shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); } } + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn buyback_restores_the_saved_property_without_reading_the_current_pool() { + let shard = fixture("property-buyback"); + shard.assert_call("debug_spawn_at_feet", &["1", "51004", "1"]); + let vendor = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51004"); + for property in [PROPERTY, "0"] { + shard.assert_call("debug_grant_item", &["1", ITEM, "1"]); + shard.assert_sql(&format!( + "UPDATE game_item_instance SET random_property_id = {property} WHERE owner_guid = 1" + )); + shard.assert_call("debug_sell_item", &["1", &vendor[0]["guid"], "23"]); + let sold = shard.query_rows( + "SELECT random_property_id FROM game_character_buyback WHERE player_guid = 1", + ); + assert_eq!(sold.len(), 1); + assert_eq!(sold[0]["random_property_id"], property); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = 5090199 WHERE entry = {ITEM}" + )); + shard.assert_call("gw_buyback_item", &["1", &vendor[0]["guid"], "0"]); + let restored = shard + .query_rows("SELECT random_property_id FROM game_item_instance WHERE owner_guid = 1"); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0]["random_property_id"], property); + assert!(shard + .query_rows("SELECT id FROM game_character_buyback WHERE player_guid = 1") + .is_empty()); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = {POOL} WHERE entry = {ITEM}" + )); + } +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn auction_refund_replay_compares_the_saved_property() { + let shard = fixture("property-auction-refund"); + let mut args = [ + "5090140", + "1", + "5090141", + ITEM, + "1", + "42", + "7748", + "false", + PROPERTY, + "1", + "5", + "5", + "100", + "500", + "120", + "10", + "1", + "7200000001", + ]; + shard.assert_call("realm_auction_refund_listing", &args); + shard.assert_call("realm_auction_refund_listing", &args); + let mails = shard.query_rows("SELECT random_property_id,item_enchant_id,item_durability,money FROM game_mail WHERE recipient_guid = 1"); + assert_eq!(mails.len(), 1); + assert_eq!(mails[0]["random_property_id"], PROPERTY); + assert_eq!(mails[0]["item_enchant_id"], "7748"); + assert_eq!(mails[0]["item_durability"], "42"); + assert_eq!(mails[0]["money"], "10"); + args[8] = "0"; + assert!(!shard + .call("realm_auction_refund_listing", &args) + .status + .success()); + assert_eq!(shard.query_rows("SELECT random_property_id,item_enchant_id,item_durability,money FROM game_mail WHERE recipient_guid = 1"), mails); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn cross_shard_transfer_imports_the_saved_item_property() { + let source = fixture("property-transfer-source"); + source.assert_call("debug_grant_item", &["1", ITEM, "1"]); + source.assert_call( + "begin_transfer", + &["5090150", "1", "0", "0", "0", "0", "0", "0", "true"], + ); + let out = source.query_rows("SELECT blob FROM game_transfer_out WHERE transfer_id = 5090150"); + let mut destination = Standalone::start("property-transfer-destination"); + destination.publish_module(); + destination.assert_call("claim_operator", &[]); + let blob = serde_json::to_string(out[0]["blob"].strip_prefix("0x").unwrap()).unwrap(); + destination.assert_call("import_character_blob", &["5090150", &blob]); + destination.assert_call("import_character_blob", &["5090150", &blob]); + let items = destination + .query_rows("SELECT random_property_id FROM game_item_instance WHERE owner_guid = 1"); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["random_property_id"], PROPERTY); + source.assert_call("confirm_import", &["5090150"]); + source.assert_call("finish_transfer", &["5090150"]); + destination.assert_call("release_transfer", &["5090150"]); + assert!(source + .query_rows("SELECT guid FROM game_item_instance WHERE owner_guid = 1") + .is_empty()); + assert_eq!( + destination + .query_rows("SELECT random_property_id FROM game_item_instance WHERE owner_guid = 1"), + items + ); +} From bdd6584b9988303532446df8c3ec1a30ecdc64ff Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:32:28 +0000 Subject: [PATCH 4/8] fix(importer): read enchantment spells from the correct DBC columns --- importer/src/item_property.rs | 166 +++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 21 deletions(-) diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index d41536ed1..98dfe287c 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -5,9 +5,7 @@ use anyhow::{bail, Context, Result}; use lyracore_shared::item_property as kind; use std::collections::{BTreeMap, BTreeSet, HashMap}; use wow_dbc::vanilla_tables::{ - item_random_properties::ItemRandomProperties, - spell::{Spell, SpellRow}, - spell_item_enchantment::SpellItemEnchantment, + item_random_properties::ItemRandomProperties, spell_item_enchantment::SpellItemEnchantment, }; use wow_dbc::DbcTable; use wow_mpq::PatchChain; @@ -21,6 +19,65 @@ struct Effect { school_mask: u32, } +#[derive(Default)] +struct EquipSpell { + id: u32, + equipped_item_class: u32, + effect: [u32; 3], + effect_die_sides: [i32; 3], + effect_base_dice: [u32; 3], + effect_dice_per_level: [f32; 3], + effect_real_points_per_level: [f32; 3], + effect_base_points: [i32; 3], + effect_aura: [u32; 3], + effect_misc_value: [u32; 3], + effect_points_per_combo: [f32; 3], +} + +/// Read build 5875 column positions. wow_dbc 0.3 omits InterruptFlags and shifts the +/// equipment fields and effect arrays, so its named SpellRow fields cannot define this catalogue. +fn read_equip_spells(bytes: &[u8]) -> Result> { + if bytes.len() < 20 || &bytes[..4] != b"WDBC" { + bail!("Spell.dbc has no WDBC header"); + } + let header = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let (count, fields, size, strings) = (header(4), header(8), header(12), header(16)); + if fields != 173 || size != 692 { + bail!("Spell.dbc requires the build 5875 layout: 173 fields, 692 bytes per row"); + } + let records_end = count.checked_mul(size).and_then(|n| n.checked_add(20)); + if records_end.and_then(|n| n.checked_add(strings)) != Some(bytes.len()) { + bail!("Spell.dbc record and string lengths do not match its header"); + } + let mut ids = BTreeSet::new(); + bytes[20..records_end.unwrap()] + .chunks_exact(size) + .map(|row| { + // Every referenced column fits the checked fixed-width record. + let column = + |at: usize| u32::from_le_bytes(row[at * 4..at * 4 + 4].try_into().unwrap()); + let array = |at: usize| std::array::from_fn(|i| column(at + i)); + let id = column(0); + if !ids.insert(id) { + bail!("duplicate Spell.dbc row {id}"); + } + Ok(EquipSpell { + id, + equipped_item_class: column(58), + effect: array(61), + effect_die_sides: array(64).map(|n| n as i32), + effect_base_dice: array(67), + effect_dice_per_level: array(70).map(f32::from_bits), + effect_real_points_per_level: array(73).map(f32::from_bits), + effect_base_points: array(76).map(|n| n as i32), + effect_aura: array(91), + effect_misc_value: array(106), + effect_points_per_combo: array(112).map(f32::from_bits), + }) + }) + .collect() +} + fn resistance_kind(school: u32) -> Option { match school { 0 => Some(kind::ARMOR), @@ -65,25 +122,26 @@ fn aura_kinds(aura: u32, misc: u32) -> Vec<(u8, u32)> { } } -fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { +fn spell_effects(slot: u8, spell: &EquipSpell) -> Result> { let mut mapped = Vec::new(); for subeffect in 0..3 { if spell.effect[subeffect] == 0 { continue; } - let aura = spell.effect_aura[subeffect] as u32; + let aura = spell.effect_aura[subeffect]; let flat = spell.effect[subeffect] == 6 && spell.effect_real_points_per_level[subeffect] == 0.0 && spell.effect_dice_per_level[subeffect] == 0.0 && spell.effect_points_per_combo[subeffect] == 0.0 - && spell.equipped_item_class.id == u32::MAX; + && spell.effect_die_sides[subeffect] <= 1 + && spell.equipped_item_class == u32::MAX; let kinds = if flat { aura_kinds(aura, spell.effect_misc_value[subeffect]) } else { vec![(kind::UNKNOWN, 0)] }; let amount = spell.effect_base_points[subeffect] - .checked_add(1) + .checked_add(i32::try_from(spell.effect_base_dice[subeffect])?) .context("enchantment spell amount overflows")?; for (expanded, (kind, school_mask)) in kinds.into_iter().enumerate() { let amount = match kind { @@ -96,7 +154,7 @@ fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { index: slot * 64 + subeffect as u8 * 8 + expanded as u8, kind, amount, - spell_id: spell.id.id, + spell_id: spell.id, school_mask, }); } @@ -106,7 +164,7 @@ fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { index: slot * 64, kind: kind::UNKNOWN, amount: 0, - spell_id: spell.id.id, + spell_id: spell.id, school_mask: 0, }); } @@ -116,25 +174,25 @@ fn spell_effects(slot: u8, spell: &SpellRow) -> Result> { pub(crate) fn catalogue_sql(chain: &mut PatchChain) -> Result<(Vec, BTreeSet)> { let properties: ItemRandomProperties = dbc::read_table(chain)?; let enchantments: SpellItemEnchantment = dbc::read_table(chain)?; - let spells: Spell = dbc::read_table(chain)?; + let spells = read_equip_spells(&chain.read_file("DBFilesClient\\Spell.dbc")?)?; catalogue_rows_sql(&properties, &enchantments, &spells) } fn catalogue_rows_sql( properties: &ItemRandomProperties, enchantments: &SpellItemEnchantment, - spells: &Spell, + spells: &[EquipSpell], ) -> Result<(Vec, BTreeSet)> { for (name, count) in [ ("ItemRandomProperties.dbc", properties.rows().len()), ("SpellItemEnchantment.dbc", enchantments.rows().len()), - ("Spell.dbc", spells.rows().len()), + ("Spell.dbc", spells.len()), ] { if count == 0 { bail!("{name} contains no rows; item catalogues were not changed"); } } - let spells: HashMap<_, _> = spells.rows().iter().map(|row| (row.id.id, row)).collect(); + let spells: HashMap<_, _> = spells.iter().map(|row| (row.id, row)).collect(); let enchant_ids: BTreeSet<_> = enchantments.rows().iter().map(|row| row.id.id).collect(); for (id, _, _) in kind::COMPATIBILITY_ENCHANTMENTS { if enchant_ids.contains(&id) { @@ -378,6 +436,71 @@ pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> #[cfg(test)] mod tests { use super::*; + + #[test] + fn spell_columns_keep_equipment_masks_out_of_all_three_effect_slots() { + let mut bytes = b"WDBC".to_vec(); + for value in [1u32, 173, 692, 1] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.resize(713, 0); + // Build 5875 columns, including the inventory mask immediately before Effect[0]. + for (column, value) in [ + (0, 509_0003), + (58, u32::MAX), + (60, 1_048_608), + (61, 6), + (62, 6), + (63, 6), + (64, 1), + (65, 1), + (66, 1), + (67, 1), + (68, 1), + (69, 1), + (76, 6), + (77, 14), + (78, 0), + (91, 29), + (92, 34), + (93, 54), + (106, 2), + ] { + let at = 20 + column * 4; + bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); + } + let spells = read_equip_spells(&bytes).unwrap(); + let effects = spell_effects(0, &spells[0]).unwrap(); + assert_eq!( + effects, + vec![ + Effect { + index: 0, + kind: kind::STAMINA, + amount: 7, + spell_id: 509_0003, + school_mask: 0 + }, + Effect { + index: 8, + kind: kind::HEALTH, + amount: 15, + spell_id: 509_0003, + school_mask: 0 + }, + Effect { + index: 16, + kind: kind::HIT, + amount: 100, + spell_id: 509_0003, + school_mask: 0 + }, + ] + ); + bytes[12..16].copy_from_slice(&688u32.to_le_bytes()); + assert!(read_equip_spells(&bytes).is_err()); + } + // Authored zero records let tests exercise the real DBC parser without client bytes. fn synthetic_table(fields: u32) -> T { let mut bytes = b"WDBC".to_vec(); @@ -388,7 +511,7 @@ mod tests { T::read(&mut std::io::Cursor::new(bytes)).unwrap() } - fn catalogues() -> (ItemRandomProperties, SpellItemEnchantment, Spell) { + fn catalogues() -> (ItemRandomProperties, SpellItemEnchantment, Vec) { let mut properties: ItemRandomProperties = synthetic_table(16); properties.rows[0].id.id = 509_0001; properties.rows[0].spell_item_enchantment = [509_0002, 0, 0, 0, 0]; @@ -398,7 +521,7 @@ mod tests { enchantments.rows[0].enchantment_type = [5, 5, 77]; enchantments.rows[0].effect_arg = [7, 4, 0]; enchantments.rows[0].effect_points_min = [7, 9, 11]; - (properties, enchantments, synthetic_table(173)) + (properties, enchantments, vec![EquipSpell::default()]) } #[test] @@ -437,7 +560,7 @@ mod tests { match missing { 0 => properties.rows.clear(), 1 => enchantments.rows.clear(), - _ => spells.rows.clear(), + _ => spells.clear(), } assert!(catalogue_rows_sql(&properties, &enchantments, &spells).is_err()); } @@ -454,12 +577,13 @@ mod tests { #[test] fn equip_spell_expands_stats_and_retains_unmapped_effects() { - let mut spells: Spell = synthetic_table(173); - let spell = &mut spells.rows[0]; - spell.id.id = 509_0003; - spell.equipped_item_class.id = u32::MAX; + let mut spell = EquipSpell::default(); + let spell = &mut spell; + spell.id = 509_0003; + spell.equipped_item_class = u32::MAX; spell.effect = [6, 6, 2]; - spell.effect_aura[0] = 29u32.try_into().unwrap(); + spell.effect_aura[0] = 29; + spell.effect_base_dice = [1; 3]; spell.effect_misc_value[0] = u32::MAX; spell.effect_base_points = [6, 8, 10]; let mapped = spell_effects(1, spell).unwrap(); From ddd9d1b36d1e9c1eec0b520b537e74af68b1e51b Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:37:03 +0000 Subject: [PATCH 5/8] fix(loot): omit invalid property drops from lootable corpses --- gateway/tests/schema_parity.rs | 3 +++ module/src/loot/mod.rs | 13 +++++++++---- module/tests/random_properties.rs | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 0207473e0..5e793f217 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -884,6 +884,9 @@ const MANIFEST_TABLES: &[&str] = &[ "game_start_position", "game_corpse", "game_item_template", + "game_item_random_property", + "game_item_enchantment", + "game_item_property_weight", "game_spell", "game_spell_effect", "game_item_instance", diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 0c72a89dd..40c70c08d 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -484,7 +484,12 @@ pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) { /// freshly unclaimed (`reserved_for = 0`) and FFA (group-loot stamping happens AFTER this returns, in /// `apply_group_loot_rules` — never at insert time). The ONE insert loop [`roll_creature_loot`] and /// [`roll_pickpocket_loot`] used to each carry a copy of (the dedup). -fn insert_corpse_rows(ctx: &ReducerContext, corpse_guid: u64, winners: Vec<(u32, u32, bool)>) { +fn insert_corpse_rows( + ctx: &ReducerContext, + corpse_guid: u64, + winners: Vec<(u32, u32, bool)>, +) -> bool { + let mut dropped = false; for (slot, (item_entry, count, quest_only)) in winners.into_iter().enumerate() { let random_property_id = match crate::items::select_loot_property(ctx, item_entry) { Ok(id) => id, @@ -508,7 +513,9 @@ fn insert_corpse_rows(ctx: &ReducerContext, corpse_guid: u64, winners: Vec<(u32, withheld: false, random_property_id, }); + dropped = true; } + dropped } /// Roll a creature's loot table into `game_corpse_loot` rows on its corpse; returns whether anything @@ -533,9 +540,7 @@ pub(crate) fn roll_creature_loot( .map(|r| (r.item_entry, r.chance_bp, r.count, r.group_id, r.quest_only)) .collect(); let winners = roll_loot_rows_quest_aware(ctx, raw); - let dropped = !winners.is_empty(); - insert_corpse_rows(ctx, corpse_guid, winners); - dropped + insert_corpse_rows(ctx, corpse_guid, winners) } // =========================================================================================== diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs index 46cf99788..4439055b3 100644 --- a/module/tests/random_properties.rs +++ b/module/tests/random_properties.rs @@ -110,6 +110,28 @@ fn create_spell(shard: &Standalone) { shard.assert_sql(&format!("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360400,5090100,0,7,2,0,0.0,0,0,0.0,0,0,0,{ITEM},8,0,0,false)")); } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_missing_property_pool_does_not_leave_an_empty_loot_cursor() { + let shard = fixture("property-missing-loot-pool"); + shard.assert_sql("DELETE FROM game_item_property_weight WHERE id = 5090100"); + shard.assert_sql("DELETE FROM game_creature_loot WHERE creature_entry = 51000"); + shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); + shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false)")); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000"); + let guid = serde_json::to_string(&wolf[0]["guid"]).unwrap(); + shard.assert_call("debug_apply_damage", &[&guid, "10", "1"]); + shard.assert_call("debug_kill_creature", &["1", &guid]); + assert!(shard + .query_rows("SELECT id FROM game_corpse_loot") + .is_empty()); + let corpse = + shard.query_rows("SELECT dynamic_flags FROM game_world_entity WHERE entry = 51000"); + let flags: u32 = corpse[0]["dynamic_flags"].parse().unwrap(); + assert_eq!(flags & 1, 0, "an omitted drop must not set LOOTABLE"); +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn large_imported_spirit_contributions_do_not_overflow_equipping() { From 84803f8646398fc972173d535c7e4a445b58bae4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:38:57 +0000 Subject: [PATCH 6/8] docs(items): name the imported enchantment catalogue --- module/src/professions.rs | 4 ++-- module/src/seed.rs | 2 +- module/src/spell/taxonomy.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/module/src/professions.rs b/module/src/professions.rs index 206001b3f..196c971f1 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -290,7 +290,7 @@ pub(crate) fn apply_fish(ctx: &ReducerContext, guid: u64) -> Result<(), String> // =========================================================================================== // ENCHANTING (completing the 13) — two reducers over the per-instance enchant overlay (`enchant_id` on -// ItemInstance + the `rules::ENCHANTS` stat table). `disenchant` consumes an equipped item → mats + +// ItemInstance + the `game_item_enchantment` catalogue). `disenchant` consumes an equipped item → mats + // skill; `enchant_item` stamps an enchant id onto an equipped instance (server-real via the // effective-* pipeline). The client glow/green-text is DEFERRED (by-entry item-stat cache). [entity] // =========================================================================================== @@ -362,7 +362,7 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res } /// ENCHANT the item in `target_slot` with `enchant_id`: validate the id (it must be a known enchant in -/// `rules::ENCHANTS`), consume the enchanting mats, stamp `enchant_id` onto the instance, climb Enchanting. +/// `game_item_enchantment`), consume the enchanting mats, stamp `enchant_id` onto the instance, climb Enchanting. /// The core (resolved guid), shared by the `enchant_item` reducer + `debug_enchant_item` twin. The enchant /// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id, ..)`, so an equipped enchanted /// piece moves the effective-* readouts (swing/dodge/armor/crit/hit + max HP/mana via recompute_vitals). diff --git a/module/src/seed.rs b/module/src/seed.rs index d7f04dc46..d6719e8f5 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -1039,7 +1039,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { // these effect rows, resolves the item GUID→bag slot, and calls enchant_item_on_slot / disenchant_item. // The effect row is the ROUTING CLASSIFIER: kind 0x17 E_ENCHANT_ITEM (p0 = enchant_id, p0_kind 10 // P_ENCHANT_ID — the gateway reads enchant_id off p0) / kind 0x18 E_DISENCHANT (no params). The enchant - // stat overlay (enchant_id→stat) lives in the module ENCHANTS table; disenchant reagents/skill in the + // stat overlay (enchant_id→stat) lives in the `game_item_enchantment` catalogue; disenchant reagents/skill in the // disenchant reducer. A NEW enchant is a data row here (a new id + p0), ZERO gateway code. target 0 is // inert (the gateway resolves the item from the cast packet, not the effect target). spell( diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index fced398b1..7d5df38c6 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -101,7 +101,7 @@ pub(crate) const E_SUMMON_PET: u8 = 0x15; // summon a persistent PET creature ow pub(crate) const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay on Hands): the vanilla HealMaxHealth effect (67) heals to max regardless of base_points (its DBC base is ~0), so it canNOT route through E_HEAL (which heals `points`, i.e. 0). Sets t.health = t.max_health and credits the full heal as threat. The importer splits effect 67 out of E_HEAL onto this kind (E_HEAL stays effect 10 only). Spell-power-agnostic (a max-heal needs no scaling) pub(crate) const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame: convert the explicit wild target into one durable Hunter identity plus a live Hunter pet pub(crate) const E_FEED_PET: u8 = 0x21; // consume an explicit item target after Hunter-pet care validation -pub(crate) const E_ENCHANT_ITEM: u8 = 0x17; // apply a permanent enchant to the cast's ITEM target: p0 = the enchant_id (p0_kind = P_ENCHANT_ID), looked up in the module ENCHANTS overlay for the stat bonus. The spell never runs through resolve_cast (it targets an item GUID, not a unit) — the GATEWAY intercepts CMSG_CAST_SPELL, sees this effect kind, resolves the item GUID→bag slot, and calls enchant_item_on_slot(slot, p0). Routing is by KIND + p0, NOT a hardcoded spell-id list, so a new enchant is a data row +pub(crate) const E_ENCHANT_ITEM: u8 = 0x17; // apply a permanent enchant to the cast's ITEM target: p0 = the enchant_id (p0_kind = P_ENCHANT_ID), looked up in the imported enchantment catalogue for the stat bonus. The spell never runs through resolve_cast (it targets an item GUID, not a unit) — the GATEWAY intercepts CMSG_CAST_SPELL, sees this effect kind, resolves the item GUID→bag slot, and calls enchant_item_on_slot(slot, p0). Routing is by KIND + p0, NOT a hardcoded spell-id list, so a new enchant is a data row pub(crate) const E_DISENCHANT: u8 = 0x18; // disenchant the cast's ITEM target into reagents: same gateway-intercept path as E_ENCHANT_ITEM, dispatched to disenchant_item(slot). No params (the module validates disenchantability + yields dust by item) pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (118, Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor pub(crate) const E_FISH: u8 = 0x1C; // Fishing (060): gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. From b23d90e09b91ed8e1fda9f06265bca613ec9cc24 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 16:47:14 +0000 Subject: [PATCH 7/8] test(items): isolate the missing property pool fixture --- module/tests/random_properties.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs index 4439055b3..0d5d4aa85 100644 --- a/module/tests/random_properties.rs +++ b/module/tests/random_properties.rs @@ -114,15 +114,18 @@ fn create_spell(shard: &Standalone) { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_missing_property_pool_does_not_leave_an_empty_loot_cursor() { let shard = fixture("property-missing-loot-pool"); + shard.assert_sql( + "UPDATE game_creature_template SET money_min = 0, money_max = 0 WHERE entry = 51000", + ); shard.assert_sql("DELETE FROM game_item_property_weight WHERE id = 5090100"); shard.assert_sql("DELETE FROM game_creature_loot WHERE creature_entry = 51000"); shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false)")); shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); let wolf = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000"); - let guid = serde_json::to_string(&wolf[0]["guid"]).unwrap(); - shard.assert_call("debug_apply_damage", &[&guid, "10", "1"]); - shard.assert_call("debug_kill_creature", &["1", &guid]); + let guid = &wolf[0]["guid"]; + shard.assert_call("debug_apply_damage", &[guid, "10", "1"]); + shard.assert_call("debug_kill_creature", &["1", guid]); assert!(shard .query_rows("SELECT id FROM game_corpse_loot") .is_empty()); From 8b72ddf55bbb9828c4873bc49c7904c8c89898fc Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 17:33:00 +0000 Subject: [PATCH 8/8] fix(items): retain valid loot and count property rows --- importer/src/dbc.rs | 2 +- importer/src/item_property.rs | 34 ++++++++++++++----- importer/src/main.rs | 21 +++++++++--- module/src/gameobject.rs | 41 +++-------------------- module/src/loot/mod.rs | 18 +++++----- module/tests/random_properties.rs | 55 +++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 62 deletions(-) diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index ac6c35dee..ca186df97 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -204,7 +204,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let (cbi_stmts, cbi_count) = char_base_info_sql(&mut chain)?; let (race_stmts, race_count) = race_info_sql(&races); let (si_stmts, si_count) = start_item_sql(&mut chain)?; - let (property_stmts, _) = crate::item_property::catalogue_sql(&mut chain)?; + let (property_stmts, _, _) = crate::item_property::catalogue_sql(&mut chain)?; // Areas / area triggers / graveyards (work-item 209): AreaTable.dbc → game_area, AreaTrigger.dbc // → game_area_trigger, WorldSafeLocs.dbc → game_graveyard. All small tables — load ALL rows (no diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index 98dfe287c..845a728b0 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -171,7 +171,7 @@ fn spell_effects(slot: u8, spell: &EquipSpell) -> Result> { Ok(mapped) } -pub(crate) fn catalogue_sql(chain: &mut PatchChain) -> Result<(Vec, BTreeSet)> { +pub(crate) fn catalogue_sql(chain: &mut PatchChain) -> Result<(Vec, BTreeSet, u64)> { let properties: ItemRandomProperties = dbc::read_table(chain)?; let enchantments: SpellItemEnchantment = dbc::read_table(chain)?; let spells = read_equip_spells(&chain.read_file("DBFilesClient\\Spell.dbc")?)?; @@ -182,7 +182,7 @@ fn catalogue_rows_sql( properties: &ItemRandomProperties, enchantments: &SpellItemEnchantment, spells: &[EquipSpell], -) -> Result<(Vec, BTreeSet)> { +) -> Result<(Vec, BTreeSet, u64)> { for (name, count) in [ ("ItemRandomProperties.dbc", properties.rows().len()), ("SpellItemEnchantment.dbc", enchantments.rows().len()), @@ -304,6 +304,7 @@ fn catalogue_rows_sql( )?; } eprintln!("Random Properties: {} properties, {} normalized enchantment effects, including two authored compatibility entries", property_rows.len(), enchant_rows.len()); + let row_count = (property_rows.len() + enchant_rows.len()) as u64; let ids = property_rows.keys().copied().collect(); let mut sql = vec![ "DELETE FROM game_item_random_property WHERE property_id > 0".into(), @@ -321,7 +322,7 @@ fn catalogue_rows_sql( "id,enchant_id,effect_index,kind,amount,spell_id,school_mask", &enchant_rows.into_values().collect::>(), ); - Ok((sql, ids)) + Ok((sql, ids, row_count)) } fn insert_effect(rows: &mut BTreeMap, enchant_id: u32, effect: Effect) -> Result<()> { @@ -378,7 +379,7 @@ pub(crate) fn template_pool(row: &[String]) -> Result { }) } -pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> { +pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result<(Vec, u64)> { let raw = parse_table(dump, "item_enchantment_template"); let required: BTreeSet = parse_table(dump, "item_template") .iter() @@ -388,13 +389,13 @@ pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> .filter(|id| *id != 0) .collect(); if raw.is_empty() && required.is_empty() { - return Ok(Vec::new()); + return Ok((Vec::new(), 0)); } let dir = dbc_dir.context( "Random Properties require --dbc to check pool and enchantment references before apply", )?; let mut chain = dbc::open_chain(std::path::Path::new(dir))?; - let (mut sql, properties) = catalogue_sql(&mut chain)?; + let (mut sql, properties, catalogue_row_count) = catalogue_sql(&mut chain)?; let mut rows = BTreeMap::new(); let mut pools = BTreeSet::new(); for row in raw { @@ -423,6 +424,7 @@ pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> rows.len(), pools.len() ); + let row_count = catalogue_row_count + rows.len() as u64; sql.push("DELETE FROM game_item_property_weight WHERE id > 0".into()); push_insert( &mut sql, @@ -430,7 +432,7 @@ pub(crate) fn dump_sql(dump: &str, dbc_dir: Option<&str>) -> Result> "id,pool_id,property_id,weight", &rows.into_values().collect::>(), ); - Ok(sql) + Ok((sql, row_count)) } #[cfg(test)] @@ -527,9 +529,14 @@ mod tests { #[test] fn catalogue_keeps_all_effect_slots_unknowns_and_compatibility_amounts() { let (properties, enchantments, spells) = catalogues(); - let (sql, ids) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); + let (sql, ids, row_count) = + catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); let sql = sql.join("\n"); assert_eq!(ids, BTreeSet::from([509_0001])); + assert_eq!( + row_count, 6, + "one property, three effects, two compatibility effects" + ); for row in [ "(5090001,5090002,0,0,'of the Fixture')", "(1303040512,5090002,0,3,7,0,0)", @@ -542,12 +549,21 @@ mod tests { } } + #[test] + fn catalogue_row_count_does_not_parse_punctuation_in_suffixes() { + let (mut properties, enchantments, spells) = catalogues(); + properties.rows[0].suffix.en_gb = "of the 'Fixture'),(another);".to_owned(); + let (_, _, row_count) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); + assert_eq!(row_count, 6); + assert_eq!(dump_sql("", None).unwrap(), (Vec::new(), 0)); + } + #[test] fn absent_linked_spells_retain_the_source_reference_as_unknown() { let (properties, mut enchantments, spells) = catalogues(); enchantments.rows[0].enchantment_type = [1, 3, 0]; enchantments.rows[0].effect_arg = [509_0098, 509_0099, 0]; - let (sql, _) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); + let (sql, _, _) = catalogue_rows_sql(&properties, &enchantments, &spells).unwrap(); let sql = sql.join("\n"); assert!(sql.contains("(1303040512,5090002,0,0,7,5090098,0)")); assert!(sql.contains("(1303040576,5090002,64,0,9,5090099,0)")); diff --git a/importer/src/main.rs b/importer/src/main.rs index c7096cfa5..0c5f29319 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -3866,9 +3866,13 @@ fn build_dump_plan( // the `import_creature_spawns` reducer (step 6), which clears every CREATURE entity + spawn // and loads the new spawns with a valid `ctx.timestamp`. let mut stmts: Vec = Vec::new(); - if family_active(args, "items") { - stmts.extend(item_property::dump_sql(dump, args.dbc.as_deref())?); - } + let property_row_count = if family_active(args, "items") { + let (property_sql, row_count) = item_property::dump_sql(dump, args.dbc.as_deref())?; + stmts.extend(property_sql); + row_count + } else { + 0 + }; push_world_content_statements(args, content, &mut stmts); push_quest_and_gameobject_statements(args, content, &mut stmts); push_creature_behaviour_statements(args, content, &mut stmts); @@ -3889,7 +3893,13 @@ fn build_dump_plan( .map(|c| c.join(";")) .collect(); - let stamps = family_stamps(args, content, globals_row_count, spellmeta_row_count); + let stamps = family_stamps( + args, + content, + property_row_count, + globals_row_count, + spellmeta_row_count, + ); let eventai_definition_count = eventai.definition_count(); let eventai_instruction_count = eventai.instruction_count(); @@ -5373,6 +5383,7 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R fn family_stamps( args: &Args, content: MappedContent, + property_row_count: u64, globals_row_count: u64, spellmeta_row_count: u64, ) -> Vec<(&'static str, u64)> { @@ -5401,7 +5412,7 @@ fn family_stamps( stamps.push(("creatures", templates.len() as u64)); } if family_active(args, "items") { - stamps.push(("items", item_rows.len() as u64)); + stamps.push(("items", item_rows.len() as u64 + property_row_count)); } if family_active(args, "loot") { stamps.push(( diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 68548b2df..389c2464f 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -14,11 +14,9 @@ use std::collections::HashSet; use spacetimedb::{reducer, table, ReducerContext, Table, TimeDuration, Timestamp}; -use crate::game_corpse_loot; use crate::game_gameobject_loot; // CHEST data-driven loot table (work-item 210) use crate::game_player_skill; // GATHER skill-gate reads the gather skill row (accessor trait) use crate::game_world_entity; -use crate::loot::CorpseLoot; use crate::nav::game_nav_chunk; // arm_pool's map-fence — neither is wildcard-exported at the crate root use crate::terrain::game_terrain_chunk; @@ -796,42 +794,11 @@ pub(crate) fn apply_use_gameobject( } else { Vec::new() }; - let winners = crate::loot::roll_loot_rows_quest_aware(ctx, raw); - if winners.is_empty() { - if tmpl.data0 != 0 { - ctx.db.game_corpse_loot().insert(CorpseLoot { - id: 0, - corpse_guid: go.guid, - slot: 0, - item_entry: tmpl.data0, - count: 1, - quest_only: false, - reserved_for: 0, - // GO/chest loot is never group-loot-gated (work-item 187 scoped to creature - // corpses) — always the FFA defaults. - designated_looter_guid: 0, - master_only: false, - withheld: false, - random_property_id: crate::items::select_loot_property(ctx, tmpl.data0)?, - }); - } - } else { - for (slot, (item_entry, count, quest_only)) in winners.into_iter().enumerate() { - ctx.db.game_corpse_loot().insert(CorpseLoot { - id: 0, - corpse_guid: go.guid, - slot: slot as u8, - item_entry, - count, - quest_only, - reserved_for: 0, - designated_looter_guid: 0, - master_only: false, - withheld: false, - random_property_id: crate::items::select_loot_property(ctx, item_entry)?, - }); - } + let mut winners = crate::loot::roll_loot_rows_quest_aware(ctx, raw); + if winners.is_empty() && tmpl.data0 != 0 { + winners.push((tmpl.data0, 1, false)); } + crate::loot::insert_corpse_rows(ctx, go.guid, winners); go.state = 1; ctx.db.game_gameobject().guid().update(go); } diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 40c70c08d..8201b53a8 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -480,17 +480,15 @@ pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) { } } -/// Insert `winners` as sequential `game_corpse_loot` rows on `corpse_guid`, starting at slot 0, each -/// freshly unclaimed (`reserved_for = 0`) and FFA (group-loot stamping happens AFTER this returns, in -/// `apply_group_loot_rules` — never at insert time). The ONE insert loop [`roll_creature_loot`] and -/// [`roll_pickpocket_loot`] used to each carry a copy of (the dedup). -fn insert_corpse_rows( +/// Insert valid drops at consecutive slots. Invalid property catalogues omit the affected item. +/// Each row starts unclaimed and FFA; creature group rules are applied by the caller. +pub(crate) fn insert_corpse_rows( ctx: &ReducerContext, corpse_guid: u64, winners: Vec<(u32, u32, bool)>, ) -> bool { - let mut dropped = false; - for (slot, (item_entry, count, quest_only)) in winners.into_iter().enumerate() { + let mut inserted = 0; + for (item_entry, count, quest_only) in winners { let random_property_id = match crate::items::select_loot_property(ctx, item_entry) { Ok(id) => id, Err(reason) => { @@ -501,7 +499,7 @@ fn insert_corpse_rows( ctx.db.game_corpse_loot().insert(CorpseLoot { id: 0, corpse_guid, - slot: slot as u8, + slot: inserted as u8, item_entry, count, quest_only, @@ -513,9 +511,9 @@ fn insert_corpse_rows( withheld: false, random_property_id, }); - dropped = true; + inserted += 1; } - dropped + inserted != 0 } /// Roll a creature's loot table into `game_corpse_loot` rows on its corpse; returns whether anything diff --git a/module/tests/random_properties.rs b/module/tests/random_properties.rs index 0d5d4aa85..bf499409a 100644 --- a/module/tests/random_properties.rs +++ b/module/tests/random_properties.rs @@ -135,6 +135,61 @@ fn a_missing_property_pool_does_not_leave_an_empty_loot_cursor() { assert_eq!(flags & 1, 0, "an omitted drop must not set LOOTABLE"); } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn invalid_creature_drops_leave_valid_loot_at_slot_zero() { + let shard = fixture("property-mixed-creature"); + shard.assert_sql("DELETE FROM game_item_property_weight WHERE id = 5090100"); + shard.assert_sql("DELETE FROM game_creature_spawn WHERE entry = 51000"); + shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); + shard.assert_sql("DELETE FROM game_creature_loot WHERE creature_entry = 51000"); + shard.assert_sql(&format!("INSERT INTO game_creature_loot (id,creature_entry,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,51000,{ITEM},10000,1,0,false),(5090101,51000,5090052,10000,2,0,false)")); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000"); + assert_eq!(wolf.len(), 1); + shard.assert_call("debug_apply_damage", &[&wolf[0]["guid"], "10", "1"]); + shard.assert_call("debug_kill_creature", &["1", &wolf[0]["guid"]]); + let loot = + shard.query_rows("SELECT slot,item_entry,count,random_property_id FROM game_corpse_loot"); + assert_eq!(loot.len(), 1); + assert_eq!(loot[0]["slot"], "0"); + assert_eq!(loot[0]["item_entry"], "5090052"); + assert_eq!(loot[0]["count"], "2"); + assert_eq!(loot[0]["random_property_id"], "0"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn invalid_chest_properties_preserve_valid_drops_and_finish_empty_fallbacks() { + let shard = fixture("property-mixed-chest"); + shard.assert_sql("UPDATE game_world_entity SET x = 100, y = 100, z = 100 WHERE guid = 1"); + shard.assert_sql("DELETE FROM game_item_property_weight WHERE id = 5090100"); + shard.assert_sql(&format!("INSERT INTO game_gameobject_loot (id,loot_id,item_entry,chance_bp,count,group_id,quest_only) VALUES (5090100,5090100,{ITEM},10000,1,0,false),(5090101,5090100,5090052,10000,2,0,false)")); + for (entry, loot_id) in [("5090110", "5090100"), ("5090111", "0")] { + shard.assert_call( + "debug_spawn_gameobject", + &[ + entry, "3", "0", ITEM, "0", "100", "100", "100", loot_id, "0", "0", "0", + ], + ); + shard.assert_call("debug_use_gameobject_entry", &["1", entry]); + let chest = shard.query_rows(&format!( + "SELECT guid,state FROM game_gameobject WHERE template_entry = {entry}" + )); + assert_eq!(chest[0]["state"], "1"); + let loot = shard.query_rows(&format!("SELECT slot,item_entry,count,random_property_id FROM game_corpse_loot WHERE corpse_guid = {}", chest[0]["guid"])); + if loot_id == "0" { + assert!(loot.is_empty()); + } else { + assert_eq!(loot.len(), 1); + assert_eq!(loot[0]["slot"], "0"); + assert_eq!(loot[0]["item_entry"], "5090052"); + assert_eq!(loot[0]["count"], "2"); + assert_eq!(loot[0]["random_property_id"], "0"); + } + } +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn large_imported_spirit_contributions_do_not_overflow_equipping() {