diff --git a/.lyracore-cli-rev b/.lyracore-cli-rev index 1f2e9ebc3..aca1ed06e 100644 --- a/.lyracore-cli-rev +++ b/.lyracore-cli-rev @@ -1 +1 @@ -ad2aa067bf5c9bf47c3c4034aaa3897c1eb6f502 +d6601ec35592eb0f18c40408ba5113846f4640e8 diff --git a/CONTEXT.md b/CONTEXT.md index a9bad2979..771db38d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -230,6 +230,14 @@ Closing a Loot Window. Makes the Loot Source available to the next eligible loot **Loot Roll**: A group's roll on an item. Lives in Realm-core. +**Loot Roll Promotion**: +Copying a Loot Roll from its source staging row to Realm-core. Its identity is the source Module +identity and the source row id. Retries preserve both values. + +**Loot Roll Promotion Receipt**: +The greatest source row id accepted for one source Module identity, corpse guid and slot. +Realm-core retains it after resolution so an older Loot Roll Promotion cannot recreate the roll. + ### Mounts **Land Mount**: diff --git a/docs/loot-roll-promotion.md b/docs/loot-roll-promotion.md new file mode 100644 index 000000000..27d74b1d9 --- /dev/null +++ b/docs/loot-roll-promotion.md @@ -0,0 +1,33 @@ +# Loot Roll promotion identity + +A promotion names the source Module identity and the source LootRoll row id. The Module stamps `promotion_source` with `ctx.database_identity()` when it stages a local roll. This is the identity of the source Module, not the Operator identity shared by Gateway connections. The Gateway copies both values from the staging row on every retry. + +Realm-core keeps one promotion receipt per source identity, corpse guid and slot. The receipt stores the greatest accepted source row id. A START with that id or an older id succeeds without creating rows. The Gateway can then clear exactly that staging row by its id. A larger id can start after the active roll on the corpse and slot has resolved. If that roll is still active, the request leaves all durable state unchanged and the Gateway keeps the staging row for retry. + +The source table's auto-increment sequence orders generations within one Module identity. Ordinary publish and restart preserve the table and its sequence. Content imports must not replace this gameplay table or insert explicit LootRoll ids. Restoring an older copy under the same identity can reuse ids and is outside this guarantee. Such a restore needs a separate reviewed recovery procedure. + +## Cleanup + +Resolution and staging cleanup remove the active LootRoll and its votes. They do not remove promotion receipts. A later accepted generation updates the existing receipt, so it also continues to cover every earlier generation. Receipts for unrelated slots or source identities have independent order. + +There is no time-based expiry. Storage grows with distinct source identities, corpse guids and slots, including dynamic creature guids. Retiring a source does not make its delayed requests invalid, so retirement alone does not permit receipt deletion. Deletion would need a durable protocol that refuses every request from the retired identity before removing its receipts. This implementation has no such operation. + +## Upgrade review + +This change adds a defaulted `Identity` column to LootRoll, a private receipt table, and two reducer arguments. The default identity is ZERO. START refuses ZERO and source row id zero, so an old staging row cannot silently enter a new identity namespace. + +Human review under `docs/danger-zones.md` section 1 is required before shipping. The reviewer must approve the additive schema, generated bindings, retained receipt storage and the following upgrade boundary: + +1. Prevent new gameplay and Operator requests from creating Loot Rolls, including bot combat. Keep the old Gateways running while existing rolls resolve so their relays can deliver winner settlement to the World Shards. +2. On every World Shard, Instance Pool and Realm-core, require zero rows in `game_loot_roll` and `game_loot_roll_vote`, and zero rows in `game_corpse_loot WHERE withheld = true`. An empty active-roll table alone does not prove that the winner received the item. Check the state rather than assuming the 60-second roll deadline completed settlement. Diagnose any remaining rows before continuing. +3. Once settlement has drained, stop every old Gateway and other Operator client that can promote or settle a roll. Keep gameplay producers quiescent. Recheck every intended destination before the first publish. +4. Publish the new Module to every destination with the ordinary non-destructive publish procedure. Do not reset tables or their auto-increment sequences. +5. Start only the Gateway build that sends the new promotion identity arguments. Old Gateway processes and their retained requests must remain stopped. + +The empty-roll checkpoint prevents an old Realm-core roll from resolving without a corresponding receipt while an old staging copy is still eligible for promotion. The withheld-loot check also prevents publishing between resolution and winner settlement. A source-column backfill alone would not establish the missing Realm-core receipt. + +The pinned `lyracore publish` checks every intended destination before its first publish. For a destination without the receipt table, it refuses pending rolls, votes, withheld loot, unreadable state or malformed query results. `--skip-preflight` does not bypass this check. The command does not stop gameplay producers or lock the destinations together. The Operator must complete the quiescence procedure above and keep it in force through the entire publish batch. + +A controlled check published approved main `8a40989` to two disposable Shards, created a source staging roll and a Realm-core roll with property 117, then cast the winning votes. Both roll tables became empty while the source still held withheld loot. Delivering the old `settle_loot_roll` request granted exactly one item and cleared the final pending row. Publishing this change then preserved that item, property 117 and the source sequence, refused the old START arguments and produced a nonzero identity on new staging rows. This checks the Module transition; the publish command's separate all-destination check enforces the checkpoint. + +No production upgrade was performed while developing this change. The regression suite uses owned disposable Standalone fixtures. diff --git a/gateway/src/stdb/bindings/debug_stage_loot_roll_fixture_reducer.rs b/gateway/src/stdb/bindings/debug_stage_loot_roll_fixture_reducer.rs new file mode 100644 index 000000000..42f1b87fc --- /dev/null +++ b/gateway/src/stdb/bindings/debug_stage_loot_roll_fixture_reducer.rs @@ -0,0 +1,62 @@ +// 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(super) struct DebugStageLootRollFixtureArgs {} + +impl From for super::Reducer { + fn from(args: DebugStageLootRollFixtureArgs) -> Self { + Self::DebugStageLootRollFixture + } +} + +impl __sdk::InModule for DebugStageLootRollFixtureArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_stage_loot_roll_fixture`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_stage_loot_roll_fixture { + /// Request that the remote module invoke the reducer `debug_stage_loot_roll_fixture` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_stage_loot_roll_fixture:debug_stage_loot_roll_fixture_then`] to run a callback after the reducer completes. + fn debug_stage_loot_roll_fixture(&self) -> __sdk::Result<()> { + self.debug_stage_loot_roll_fixture_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_stage_loot_roll_fixture` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_stage_loot_roll_fixture_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_stage_loot_roll_fixture for super::RemoteReducers { + fn debug_stage_loot_roll_fixture_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugStageLootRollFixtureArgs {}, callback) + } +} diff --git a/gateway/src/stdb/bindings/game_loot_roll_promotion_receipt_table.rs b/gateway/src/stdb/bindings/game_loot_roll_promotion_receipt_table.rs new file mode 100644 index 000000000..9cd4ab445 --- /dev/null +++ b/gateway/src/stdb/bindings/game_loot_roll_promotion_receipt_table.rs @@ -0,0 +1,235 @@ +// 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::loot_roll_promotion_receipt_type::LootRollPromotionReceipt; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_loot_roll_promotion_receipt`. +/// +/// Obtain a handle from the [`GameLootRollPromotionReceiptTableAccess::game_loot_roll_promotion_receipt`] method on [`super::RemoteTables`], +/// like `ctx.db.game_loot_roll_promotion_receipt()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_loot_roll_promotion_receipt().on_insert(...)`. +pub struct GameLootRollPromotionReceiptTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_loot_roll_promotion_receipt`. +pub struct GameLootRollPromotionReceiptTableAccessor; + +impl __sdk::TableAccessor for GameLootRollPromotionReceiptTableAccessor { + type Row = LootRollPromotionReceipt; + type Handle<'db> = GameLootRollPromotionReceiptTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_loot_roll_promotion_receipt() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_loot_roll_promotion_receipt`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameLootRollPromotionReceiptTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameLootRollPromotionReceiptTableHandle`], which mediates access to the table `game_loot_roll_promotion_receipt`. + fn game_loot_roll_promotion_receipt(&self) -> GameLootRollPromotionReceiptTableHandle<'_>; +} + +impl GameLootRollPromotionReceiptTableAccess for super::RemoteTables { + fn game_loot_roll_promotion_receipt(&self) -> GameLootRollPromotionReceiptTableHandle<'_> { + GameLootRollPromotionReceiptTableHandle { + imp: self + .imp + .get_table::("game_loot_roll_promotion_receipt"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameLootRollPromotionReceiptInsertCallbackId(__sdk::CallbackId); +pub struct GameLootRollPromotionReceiptDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameLootRollPromotionReceiptTableHandle<'ctx> { + type Row = LootRollPromotionReceipt; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameLootRollPromotionReceiptTableHandle<'ctx> { + type Row = LootRollPromotionReceipt; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameLootRollPromotionReceiptInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptInsertCallbackId { + GameLootRollPromotionReceiptInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameLootRollPromotionReceiptInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameLootRollPromotionReceiptDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptDeleteCallbackId { + GameLootRollPromotionReceiptDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameLootRollPromotionReceiptDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameLootRollPromotionReceiptTableHandle<'ctx> { + type InsertCallbackId = GameLootRollPromotionReceiptInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptInsertCallbackId { + GameLootRollPromotionReceiptInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameLootRollPromotionReceiptInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameLootRollPromotionReceiptTableHandle<'ctx> { + type DeleteCallbackId = GameLootRollPromotionReceiptDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptDeleteCallbackId { + GameLootRollPromotionReceiptDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameLootRollPromotionReceiptDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameLootRollPromotionReceiptUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameLootRollPromotionReceiptTableHandle<'ctx> { + type UpdateCallbackId = GameLootRollPromotionReceiptUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptUpdateCallbackId { + GameLootRollPromotionReceiptUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameLootRollPromotionReceiptUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameLootRollPromotionReceiptTableHandle<'ctx> { + type UpdateCallbackId = GameLootRollPromotionReceiptUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameLootRollPromotionReceiptUpdateCallbackId { + GameLootRollPromotionReceiptUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameLootRollPromotionReceiptUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `id` unique index on the table `game_loot_roll_promotion_receipt`, +/// which allows point queries on the field of the same name +/// via the [`GameLootRollPromotionReceiptIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_loot_roll_promotion_receipt().id().find(...)`. +pub struct GameLootRollPromotionReceiptIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameLootRollPromotionReceiptTableHandle<'ctx> { + /// Get a handle on the `id` unique index on the table `game_loot_roll_promotion_receipt`. + pub fn id(&self) -> GameLootRollPromotionReceiptIdUnique<'ctx> { + GameLootRollPromotionReceiptIdUnique { + imp: self.imp.get_unique_constraint::("id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameLootRollPromotionReceiptIdUnique<'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_loot_roll_promotion_receipt"); + _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 `LootRollPromotionReceipt`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_loot_roll_promotion_receiptQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `LootRollPromotionReceipt`. + fn game_loot_roll_promotion_receipt( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl game_loot_roll_promotion_receiptQueryTableAccess for __sdk::QueryTableAccessor { + fn game_loot_roll_promotion_receipt( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_loot_roll_promotion_receipt") + } +} diff --git a/gateway/src/stdb/bindings/loot_roll_promotion_receipt_type.rs b/gateway/src/stdb/bindings/loot_roll_promotion_receipt_type.rs new file mode 100644 index 000000000..23d6b9a01 --- /dev/null +++ b/gateway/src/stdb/bindings/loot_roll_promotion_receipt_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 LootRollPromotionReceipt { + pub id: u64, + pub promotion_source: __sdk::Identity, + pub corpse_guid: u64, + pub slot: u8, + pub source_roll_id: u64, +} + +impl __sdk::InModule for LootRollPromotionReceipt { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `LootRollPromotionReceipt`. +/// +/// Provides typed access to columns for query building. +pub struct LootRollPromotionReceiptCols { + pub id: __sdk::__query_builder::Col, + pub promotion_source: __sdk::__query_builder::Col, + pub corpse_guid: __sdk::__query_builder::Col, + pub slot: __sdk::__query_builder::Col, + pub source_roll_id: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for LootRollPromotionReceipt { + type Cols = LootRollPromotionReceiptCols; + fn cols(table_name: &'static str) -> Self::Cols { + LootRollPromotionReceiptCols { + id: __sdk::__query_builder::Col::new(table_name, "id"), + promotion_source: __sdk::__query_builder::Col::new(table_name, "promotion_source"), + corpse_guid: __sdk::__query_builder::Col::new(table_name, "corpse_guid"), + slot: __sdk::__query_builder::Col::new(table_name, "slot"), + source_roll_id: __sdk::__query_builder::Col::new(table_name, "source_roll_id"), + } + } +} + +/// Indexed column accessor struct for the table `LootRollPromotionReceipt`. +/// +/// Provides typed access to indexed columns for query building. +pub struct LootRollPromotionReceiptIxCols { + pub id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for LootRollPromotionReceipt { + type IxCols = LootRollPromotionReceiptIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + LootRollPromotionReceiptIxCols { + id: __sdk::__query_builder::IxCol::new(table_name, "id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for LootRollPromotionReceipt {} diff --git a/gateway/src/stdb/bindings/loot_roll_type.rs b/gateway/src/stdb/bindings/loot_roll_type.rs index 2fa369e22..378aa6c53 100644 --- a/gateway/src/stdb/bindings/loot_roll_type.rs +++ b/gateway/src/stdb/bindings/loot_roll_type.rs @@ -14,6 +14,7 @@ pub struct LootRoll { pub deadline_micros: i64, pub resolved: bool, pub random_property_id: u32, + pub promotion_source: __sdk::Identity, } impl __sdk::InModule for LootRoll { @@ -31,6 +32,7 @@ pub struct LootRollCols { pub deadline_micros: __sdk::__query_builder::Col, pub resolved: __sdk::__query_builder::Col, pub random_property_id: __sdk::__query_builder::Col, + pub promotion_source: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for LootRoll { @@ -44,6 +46,7 @@ impl __sdk::__query_builder::HasCols for LootRoll { 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"), + promotion_source: __sdk::__query_builder::Col::new(table_name, "promotion_source"), } } } diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 7f2067b91..e097766e9 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -235,6 +235,7 @@ pub mod debug_stage_auction_expiry_fixture_reducer; pub mod debug_stage_choice_reward_fixture_reducer; pub mod debug_stage_eventai_revision_fixture_reducer; pub mod debug_stage_lethal_damage_floor_fixture_reducer; +pub mod debug_stage_loot_roll_fixture_reducer; pub mod debug_stage_ranged_lethal_damage_floor_fixture_reducer; pub mod debug_stress_relay_reducer; pub mod debug_sweep_encounter_state_reducer; @@ -451,6 +452,7 @@ pub mod game_levelup_event_table; pub mod game_live_pet_kind_table; pub mod game_lock_table; pub mod game_lock_type; +pub mod game_loot_roll_promotion_receipt_table; pub mod game_loot_roll_table; pub mod game_loot_roll_vote_table; pub mod game_mail_delivery_table; @@ -729,6 +731,7 @@ pub mod kill_credit_type; pub mod level_stats_type; pub mod levelup_event_type; pub mod live_pet_kind_type; +pub mod loot_roll_promotion_receipt_type; pub mod loot_roll_type; pub mod loot_roll_vote_type; pub mod mail_delivery_type; @@ -1210,6 +1213,7 @@ pub use debug_stage_auction_expiry_fixture_reducer::debug_stage_auction_expiry_f pub use debug_stage_choice_reward_fixture_reducer::debug_stage_choice_reward_fixture; pub use debug_stage_eventai_revision_fixture_reducer::debug_stage_eventai_revision_fixture; pub use debug_stage_lethal_damage_floor_fixture_reducer::debug_stage_lethal_damage_floor_fixture; +pub use debug_stage_loot_roll_fixture_reducer::debug_stage_loot_roll_fixture; pub use debug_stage_ranged_lethal_damage_floor_fixture_reducer::debug_stage_ranged_lethal_damage_floor_fixture; pub use debug_stress_relay_reducer::debug_stress_relay; pub use debug_sweep_encounter_state_reducer::debug_sweep_encounter_state; @@ -1426,6 +1430,7 @@ pub use game_levelup_event_table::*; pub use game_live_pet_kind_table::*; pub use game_lock_table::*; pub use game_lock_type::GameLock; +pub use game_loot_roll_promotion_receipt_table::*; pub use game_loot_roll_table::*; pub use game_loot_roll_vote_table::*; pub use game_mail_delivery_table::*; @@ -1704,6 +1709,7 @@ pub use kill_credit_type::KillCredit; pub use level_stats_type::LevelStats; pub use levelup_event_type::LevelupEvent; pub use live_pet_kind_type::LivePetKind; +pub use loot_roll_promotion_receipt_type::LootRollPromotionReceipt; pub use loot_roll_type::LootRoll; pub use loot_roll_vote_type::LootRollVote; pub use mail_delivery_type::MailDelivery; @@ -2566,6 +2572,7 @@ pub enum Reducer { DebugStageLethalDamageFloorFixture { creature_guid: u64, }, + DebugStageLootRollFixture, DebugStageRangedLethalDamageFloorFixture { attacker_guid: u64, target_guid: u64, @@ -3334,6 +3341,8 @@ pub enum Reducer { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: __sdk::Identity, + source_roll_id: u64, }, RealmMailCommit { escrow_id: u64, @@ -3732,6 +3741,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugStageLethalDamageFloorFixture { .. } => { "debug_stage_lethal_damage_floor_fixture" } + Reducer::DebugStageLootRollFixture => "debug_stage_loot_roll_fixture", Reducer::DebugStageRangedLethalDamageFloorFixture { .. } => { "debug_stage_ranged_lethal_damage_floor_fixture" } @@ -5054,7 +5064,9 @@ Reducer::DebugStageChoiceRewardFixture{ } => __sats::bsatn::to_vec(&debug_stage_lethal_damage_floor_fixture_reducer::DebugStageLethalDamageFloorFixtureArgs { creature_guid: creature_guid.clone(), }), - Reducer::DebugStageRangedLethalDamageFloorFixture{ + Reducer::DebugStageLootRollFixture => __sats::bsatn::to_vec(&debug_stage_loot_roll_fixture_reducer::DebugStageLootRollFixtureArgs { + }), +Reducer::DebugStageRangedLethalDamageFloorFixture{ attacker_guid, target_guid, damage, @@ -6418,6 +6430,8 @@ Reducer::PrepareVmapNavCoverage{ deadline_micros, recipients, random_property_id, + promotion_source, + source_roll_id, } => __sats::bsatn::to_vec(&realm_loot_op_reducer::RealmLootOpArgs { op: op.clone(), corpse_guid: corpse_guid.clone(), @@ -6428,6 +6442,8 @@ Reducer::PrepareVmapNavCoverage{ deadline_micros: deadline_micros.clone(), recipients: recipients.clone(), random_property_id: random_property_id.clone(), + promotion_source: promotion_source.clone(), + source_roll_id: source_roll_id.clone(), }), Reducer::RealmMailCommit{ escrow_id, @@ -6989,6 +7005,7 @@ pub struct DbUpdate { game_live_pet_kind: __sdk::TableUpdate, game_lock: __sdk::TableUpdate, game_loot_roll: __sdk::TableUpdate, + game_loot_roll_promotion_receipt: __sdk::TableUpdate, game_loot_roll_vote: __sdk::TableUpdate, game_mail: __sdk::TableUpdate, game_mail_delivery: __sdk::TableUpdate, @@ -7577,6 +7594,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_loot_roll" => db_update .game_loot_roll .append(game_loot_roll_table::parse_table_update(table_update)?), + "game_loot_roll_promotion_receipt" => { + db_update.game_loot_roll_promotion_receipt.append( + game_loot_roll_promotion_receipt_table::parse_table_update(table_update)?, + ) + } "game_loot_roll_vote" => db_update .game_loot_roll_vote .append(game_loot_roll_vote_table::parse_table_update(table_update)?), @@ -8588,6 +8610,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.game_loot_roll = cache .apply_diff_to_table::("game_loot_roll", &self.game_loot_roll) .with_updates_by_pk(|row| &row.id); + diff.game_loot_roll_promotion_receipt = cache + .apply_diff_to_table::( + "game_loot_roll_promotion_receipt", + &self.game_loot_roll_promotion_receipt, + ) + .with_updates_by_pk(|row| &row.id); diff.game_loot_roll_vote = cache .apply_diff_to_table::("game_loot_roll_vote", &self.game_loot_roll_vote) .with_updates_by_pk(|row| &row.id); @@ -9441,6 +9469,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_loot_roll" => db_update .game_loot_roll .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_loot_roll_promotion_receipt" => db_update + .game_loot_roll_promotion_receipt + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_loot_roll_vote" => db_update .game_loot_roll_vote .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -10186,6 +10217,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_loot_roll" => db_update .game_loot_roll .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_loot_roll_promotion_receipt" => db_update + .game_loot_roll_promotion_receipt + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_loot_roll_vote" => db_update .game_loot_roll_vote .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -10652,6 +10686,7 @@ pub struct AppliedDiff<'r> { game_live_pet_kind: __sdk::TableAppliedDiff<'r, LivePetKind>, game_lock: __sdk::TableAppliedDiff<'r, GameLock>, game_loot_roll: __sdk::TableAppliedDiff<'r, LootRoll>, + game_loot_roll_promotion_receipt: __sdk::TableAppliedDiff<'r, LootRollPromotionReceipt>, game_loot_roll_vote: __sdk::TableAppliedDiff<'r, LootRollVote>, game_mail: __sdk::TableAppliedDiff<'r, Mail>, game_mail_delivery: __sdk::TableAppliedDiff<'r, MailDelivery>, @@ -11441,6 +11476,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_loot_roll, event, ); + callbacks.invoke_table_row_callbacks::( + "game_loot_roll_promotion_receipt", + &self.game_loot_roll_promotion_receipt, + event, + ); callbacks.invoke_table_row_callbacks::( "game_loot_roll_vote", &self.game_loot_roll_vote, @@ -12729,6 +12769,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_live_pet_kind_table::register_table(client_cache); game_lock_table::register_table(client_cache); game_loot_roll_table::register_table(client_cache); + game_loot_roll_promotion_receipt_table::register_table(client_cache); game_loot_roll_vote_table::register_table(client_cache); game_mail_table::register_table(client_cache); game_mail_delivery_table::register_table(client_cache); @@ -12975,6 +13016,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_live_pet_kind", "game_lock", "game_loot_roll", + "game_loot_roll_promotion_receipt", "game_loot_roll_vote", "game_mail", "game_mail_delivery", diff --git a/gateway/src/stdb/bindings/realm_loot_op_reducer.rs b/gateway/src/stdb/bindings/realm_loot_op_reducer.rs index 57743b810..a253b0733 100644 --- a/gateway/src/stdb/bindings/realm_loot_op_reducer.rs +++ b/gateway/src/stdb/bindings/realm_loot_op_reducer.rs @@ -16,6 +16,8 @@ pub(super) struct RealmLootOpArgs { pub deadline_micros: i64, pub recipients: Vec, pub random_property_id: u32, + pub promotion_source: __sdk::Identity, + pub source_roll_id: u64, } impl From for super::Reducer { @@ -30,6 +32,8 @@ impl From for super::Reducer { deadline_micros: args.deadline_micros, recipients: args.recipients, random_property_id: args.random_property_id, + promotion_source: args.promotion_source, + source_roll_id: args.source_roll_id, } } } @@ -60,6 +64,8 @@ pub trait realm_loot_op { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: __sdk::Identity, + source_roll_id: u64, ) -> __sdk::Result<()> { self.realm_loot_op_then( op, @@ -71,6 +77,8 @@ pub trait realm_loot_op { deadline_micros, recipients, random_property_id, + promotion_source, + source_roll_id, |_, _| {}, ) } @@ -92,6 +100,8 @@ pub trait realm_loot_op { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: __sdk::Identity, + source_roll_id: u64, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -111,6 +121,8 @@ impl realm_loot_op for super::RemoteReducers { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: __sdk::Identity, + source_roll_id: u64, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -127,6 +139,8 @@ impl realm_loot_op for super::RemoteReducers { deadline_micros, recipients, random_property_id, + promotion_source, + source_roll_id, }, callback, ) diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index 17086ab45..00abb8882 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -111,6 +111,7 @@ impl Coordinator { deadline_micros: r.deadline_micros, recipients, random_property_id: r.random_property_id, + promotion_source: r.promotion_source, } }) .collect()) diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 8a8f460c5..4bcfe3b38 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -2952,6 +2952,8 @@ impl Coordinator { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: spacetimedb_sdk::Identity, + source_roll_id: u64, ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -2965,7 +2967,9 @@ impl Coordinator { vote, deadline_micros, recipients, - random_property_id + random_property_id, + promotion_source, + source_roll_id ) ) } @@ -2990,6 +2994,8 @@ impl Coordinator { vote, 0, Vec::new(), + 0, + spacetimedb_sdk::Identity::ZERO, 0 ) )) diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index 5f736a34f..55e54dbca 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -1172,6 +1172,8 @@ impl WorldStore for Coordinator { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: spacetimedb_sdk::Identity, + source_roll_id: u64, ) -> Result<()> { self.realm_loot_op( op, @@ -1183,6 +1185,8 @@ impl WorldStore for Coordinator { deadline_micros, recipients, random_property_id, + promotion_source, + source_roll_id, ) } diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index 580b2636f..153ae08c1 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -106,6 +106,7 @@ pub struct PendingLootRoll { pub deadline_micros: i64, /// Every eligible voter, snapshotted at kill time (`game_loot_roll_vote`'s rows for this roll). pub recipients: Vec, + pub promotion_source: spacetimedb_sdk::Identity, } /// Route `CMSG_LOOT_ROLL` for the session that owns `self_guid`. @@ -144,6 +145,8 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo roll.deadline_micros, roll.recipients.clone(), roll.random_property_id, + roll.promotion_source, + roll.roll_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 6c550d414..06948c2de 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -13,6 +13,8 @@ use super::party_tests::{form_split_party, party_topology, GINGER, TRIN, VIM}; use super::*; use lyracore_shared::loot_roll::loot_op; +const SOURCE: spacetimedb_sdk::Identity = spacetimedb_sdk::Identity::from_byte_array([7; 32]); + // ---- `run_vote` (CMSG_LOOT_ROLL routing) ---- /// Unsharded → the shard-local loot path, verbatim: the vote runs on the player's own reducer, and @@ -66,6 +68,8 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { lyracore_shared::loot_roll::vote_kind::GREED, 0, vec![], + 0, + spacetimedb_sdk::Identity::ZERO, 0 )], "sharded, the vote must reach REALM-CORE, addressed by the AUTHENTICATED guid" @@ -88,6 +92,7 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() ..Default::default() }); *peer.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + promotion_source: SOURCE, roll_id: 1, corpse_guid: 500, slot: 2, @@ -119,6 +124,7 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() fn relay_tick_promotes_a_staging_roll_and_clears_it() { let (realm, world, instances, _calls) = party_topology(); *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + promotion_source: SOURCE, roll_id: 77, corpse_guid: 500, slot: 2, @@ -140,7 +146,9 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { 0, 999_999, vec![GINGER, TRIN], - 0x1234_5678 + 0x1234_5678, + SOURCE, + 77 )], "the promoted roll must carry the ORIGINAL deadline and the FULL recipient snapshot" ); @@ -155,6 +163,41 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { ); } +#[test] +fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { + let realm = std::sync::Arc::new(InMemoryStore { + realm_loot_op_error: Some("another Loot Roll is active in this slot".into()), + ..Default::default() + }); + let world = std::sync::Arc::new(InMemoryStore { + realm: Some(realm.clone()), + ..Default::default() + }); + *world.peers.lock().unwrap() = vec![world.clone()]; + let pending = loot::PendingLootRoll { + roll_id: 78, + corpse_guid: 500, + slot: 2, + item_entry: 1234, + deadline_micros: 999_999, + recipients: vec![GINGER, TRIN], + random_property_id: 117, + promotion_source: SOURCE, + }; + *world.pending_rolls.lock().unwrap() = vec![pending.clone()]; + let mut watermark = 0; + loot::relay_tick(world.as_ref(), &mut watermark); + loot::relay_tick(world.as_ref(), &mut watermark); + assert_eq!(*world.pending_rolls.lock().unwrap(), vec![pending]); + assert!(world.cleared_rolls.lock().unwrap().is_empty()); + let attempts = realm.realm_loot_ops.lock().unwrap(); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0], attempts[1]); + assert_eq!(attempts[0].8, 117); + assert_eq!(attempts[0].9, SOURCE); + assert_eq!(attempts[0].10, 78); +} + /// A resolved roll's winner is settled on EVERY connected world shard — the module's own `withheld` /// guard (not the relay) is what makes the wrong shards' calls harmless, so the relay fans out rather /// than trying to guess which shard holds the corpse. @@ -204,6 +247,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); // Ginger (world) + Vim (instances) — a 2-member party *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + promotion_source: SOURCE, roll_id: 99, corpse_guid: 500, slot: 2, @@ -219,7 +263,19 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach assert_eq!( realm.realm_loot_ops.lock().unwrap().clone(), - vec![(loot_op::START, 500, 2, 1234, 0, 0, 999, vec![GINGER], 0)], + vec![( + loot_op::START, + 500, + 2, + 1234, + 0, + 0, + 999, + vec![GINGER], + 0, + SOURCE, + 99 + )], "the pending roll on the OTHER shard must be promoted before the disband, not left behind" ); assert_eq!( @@ -255,6 +311,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach fn a_non_disband_capable_op_never_flushes_pending_rolls() { let (realm, world, _instances, _calls) = party_topology(); *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + promotion_source: SOURCE, roll_id: 1, corpse_guid: 500, slot: 2, diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 3b17f3413..a56000094 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -650,6 +650,7 @@ fn reconnect_reconciliation_removes_a_member_whose_delete_event_was_missed() { fn deleted_character_cleanup_flushes_pending_loot_before_leaving_realm_core() { let (_realm, world, instances, calls) = topology_after_vim_is_deleted(); *instances.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + promotion_source: spacetimedb_sdk::Identity::from_byte_array([7; 32]), roll_id: 40, corpse_guid: 90, slot: 1, diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index 4853c6731..7f0daa2b6 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -299,6 +299,8 @@ pub trait WorldStore: _deadline_micros: i64, _recipients: Vec, _random_property_id: u32, + _promotion_source: spacetimedb_sdk::Identity, + _source_roll_id: u64, ) -> Result<()> { Err(anyhow!("this store does not host realm-wide loot rolls")) } @@ -322,6 +324,8 @@ pub trait WorldStore: 0, Vec::new(), 0, + spacetimedb_sdk::Identity::ZERO, + 0, )?; Ok(LootActionStatus::Applied) } diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 211a54d51..259335df5 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -665,14 +665,24 @@ struct InMemoryStore { /// `"mover not in world"` — the module's answer for a packet that arrives after /// `teleport_player` despawned the entity, i.e. the tail of every cross-map port. movement_error: Option, - // Test recorder: the tuple is `realm_loot_op`'s argument list verbatim. + /// Records every `realm_loot_op` argument in wire order. The Realm-core handle owns the + /// recorder so a test can distinguish authority routing from a Shard-local request. #[allow(clippy::type_complexity)] - /// Recorded `realm_loot_op` calls — `(op, corpse_guid, slot, item_entry, actor_guid, vote, - /// deadline_micros, recipients, random_property_id)` — every arg the gateway's loot-roll - /// routing/relay passed. The - /// realm handle owns this; a world shard's staying empty is how a test tells "the vote/promotion - /// went to the authority" from "it stayed shard-local". - realm_loot_ops: std::sync::Mutex, u32)>>, + realm_loot_ops: std::sync::Mutex< + Vec<( + u8, + u64, + u8, + u32, + u64, + u8, + i64, + Vec, + u32, + spacetimedb_sdk::Identity, + u64, + )>, + >, /// When set, `realm_loot_op` fails with this message. realm_loot_op_error: Option, /// This WORLD SHARD's staging rolls `pending_local_rolls` answers — the relay's promotion @@ -2741,6 +2751,8 @@ impl WorldStore for InMemoryStore { deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: spacetimedb_sdk::Identity, + source_roll_id: u64, ) -> Result<()> { self.rec("realm_loot_op"); self.realm_loot_ops.lock().unwrap().push(( @@ -2753,6 +2765,8 @@ impl WorldStore for InMemoryStore { deadline_micros, recipients, random_property_id, + promotion_source, + source_roll_id, )); if let Some(e) = &self.realm_loot_op_error { return Err(anyhow!("{e}")); @@ -2777,6 +2791,8 @@ impl WorldStore for InMemoryStore { 0, Vec::new(), 0, + spacetimedb_sdk::Identity::ZERO, + 0, )?; if let Some(failure) = &self.loot_action_failure { return Err(anyhow!(failure.clone())); @@ -2805,6 +2821,10 @@ impl WorldStore for InMemoryStore { fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { self.rec("clear_promoted_loot_roll"); self.cleared_rolls.lock().unwrap().push(roll_id); + self.pending_rolls + .lock() + .unwrap() + .retain(|r| r.roll_id != roll_id); Ok(()) } diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 5e793f217..60b91911c 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -464,7 +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, + random_property_id, promotion_source, }); 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, diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index cd4623528..50e28afb6 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -53,7 +53,7 @@ //! their new location. No special-casing was needed for this — it falls out of routing votes to //! realm-core rather than to "the shard that created the roll". -use spacetimedb::{reducer, table, ReducerContext, Table}; +use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; use crate::game_corpse_loot; use crate::game_group; // Group row: loot_method/loot_threshold/rr_cursor/master_looter_guid @@ -99,6 +99,22 @@ pub struct LootRoll { pub resolved: bool, #[default(0)] pub random_property_id: u32, + /// Source Module identity carried unchanged by promotion retries. + #[default(Identity::ZERO)] + pub promotion_source: Identity, +} + +/// One Loot Roll Promotion Receipt per source Module identity, corpse and slot. Resolution keeps +/// this high-water mark; replacing it with a later source id also covers every older replay. +#[table(accessor = game_loot_roll_promotion_receipt, index(accessor = by_source_slot, btree(columns = [promotion_source, corpse_guid, slot])))] +pub struct LootRollPromotionReceipt { + #[primary_key] + #[auto_inc] + pub id: u64, + pub promotion_source: Identity, + pub corpse_guid: u64, + pub slot: u8, + pub source_roll_id: u64, } /// One eligible member's vote on a [`LootRoll`] — snapshotted (one row per eligible guid) the moment @@ -340,7 +356,8 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro /// Create one Loot Roll and its votes, or return the existing roll for this corpse and slot. /// Repeated starts preserve votes, recipients and deadline. No event is emitted here. -/// Resolution deletes these rows, so a later replay after resolution is not deduplicated. +/// Promotion checks its durable receipt before reaching this local row creation path. +#[allow(clippy::too_many_arguments)] fn insert_roll_rows( ctx: &ReducerContext, corpse_guid: u64, @@ -349,6 +366,7 @@ fn insert_roll_rows( recipients: &[u64], deadline_micros: i64, random_property_id: u32, + promotion_source: Identity, ) -> u64 { let rolls = ctx.db.game_loot_roll(); if let Some(existing) = rolls @@ -366,6 +384,7 @@ fn insert_roll_rows( deadline_micros, resolved: false, random_property_id, + promotion_source, }); let votes = ctx.db.game_loot_roll_vote(); for &guid in recipients { @@ -406,6 +425,7 @@ fn start_roll( recipients, now_micros + ROLL_WINDOW_MICROS, random_property_id, + ctx.database_identity(), ); let payload = lyracore_shared::loot_roll::encode_start( corpse_guid, @@ -419,6 +439,23 @@ fn start_roll( } } +/// Stage a reserved Loot Roll through the same creation path as a killing blow. +#[cfg(feature = "debug_reducers")] +#[reducer] +pub fn debug_stage_loot_roll_fixture(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + start_roll( + ctx, + 5_090_001, + 0, + 5_090_002, + &[5_090_003, 5_090_004, 5_090_005], + ctx.timestamp.to_micros_since_unix_epoch(), + 117, + ); + Ok(()) +} + /// The identity-free vote core (mirrors `group.rs`'s `*_on` cores): shared by the player /// reducer above (the world shard the voter is physically standing on) and `realm_loot_op`'s VOTE arm /// (realm-core, once the roll has been promoted there). Same gates, same resolution, in the same @@ -774,30 +811,96 @@ pub fn realm_loot_op( deadline_micros: i64, recipients: Vec, random_property_id: u32, + promotion_source: Identity, + source_roll_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; use lyracore_shared::loot_roll::loot_op; match op { - // The gateway's loot-roll relay PROMOTING a roll a world shard already announced locally - // (`gateway/src/world/loot.rs::relay_tick`) — `insert_roll_rows`, never `start_roll`: the - // `ROLL_START` popup already fired on the shard that created it, so this must not re-push it. - loot_op::START => { - insert_roll_rows( - ctx, - corpse_guid, - slot, - item_entry, - &recipients, - deadline_micros, - random_property_id, - ); - Ok(()) - } + loot_op::START => accept_promotion( + ctx, + corpse_guid, + slot, + item_entry, + &recipients, + deadline_micros, + random_property_id, + promotion_source, + source_roll_id, + ), loot_op::VOTE => cast_vote_on(ctx, corpse_guid, slot, actor_guid, vote), other => Err(format!("unknown realm loot op {other}")), } } +/// A replay is an acknowledged no-op, even after resolution. A newer generation waits for the +/// current roll to resolve. Both outcomes leave that current roll and its votes unchanged. +#[allow(clippy::too_many_arguments)] +fn accept_promotion( + ctx: &ReducerContext, + corpse_guid: u64, + slot: u8, + item_entry: u32, + recipients: &[u64], + deadline_micros: i64, + random_property_id: u32, + promotion_source: Identity, + source_roll_id: u64, +) -> Result<(), String> { + if promotion_source == Identity::ZERO || source_roll_id == 0 { + return Err(refused( + LootRefusal::RollUnavailable, + "promotion identity is missing", + )); + } + let receipts = ctx.db.game_loot_roll_promotion_receipt(); + let receipt = receipts + .by_source_slot() + .filter((promotion_source, corpse_guid, slot)) + .next(); + if receipt + .as_ref() + .is_some_and(|r| r.source_roll_id >= source_roll_id) + { + return Ok(()); + } + if ctx + .db + .game_loot_roll() + .by_corpse() + .filter(&corpse_guid) + .any(|r| r.slot == slot) + { + return Err(refused( + LootRefusal::RollUnavailable, + "another Loot Roll is active in this slot", + )); + } + insert_roll_rows( + ctx, + corpse_guid, + slot, + item_entry, + recipients, + deadline_micros, + random_property_id, + promotion_source, + ); + if let Some(mut receipt) = receipt { + receipt.source_roll_id = source_roll_id; + receipts.id().update(receipt); + } else { + receipts.insert(LootRollPromotionReceipt { + id: 0, + promotion_source, + corpse_guid, + slot, + source_roll_id, + }); + } + Ok(()) +} + /// Grant a resolved roll's item on the WORLD SHARD that actually holds the corpse. The /// gateway's loot-roll relay calls this on every connected world shard after observing realm-core's /// `ROLL_WON` event — [`settle_roll_grant`]'s own guards make every wrong-shard call a harmless @@ -1050,31 +1153,6 @@ mod tests { } } - /// The op byte is a wire value the gateway sends and `realm_loot_op` dispatches on — - /// `lyracore_shared::loot_roll::loot_op` pins only the NUMBERS. This pins what each number DOES: a - /// swapped arm would silently run VOTE for a START, corrupting a fresh roll's recipient snapshot. - #[test] - 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, random_property_id, ); Ok(()) }"), - ("loot_op::VOTE =>", "cast_vote_on(ctx, corpse_guid, slot, actor_guid, vote)"), - ] { - let arm = body - .split(op) - .nth(1) - .unwrap_or_else(|| panic!("`realm_loot_op` no longer dispatches `{op}`. Body was:\n{body}")); - let arm: String = arm.split_whitespace().collect::>().join(" "); - assert!( - arm.starts_with(core), - "`{op}` no longer runs `{core}` — the op byte is a WIRE value the gateway sends and \ - this match dispatches on; a swapped arm runs the wrong op for every roll, silently. \ - Arm was:\n{}", - &arm[..arm.len().min(140)] - ); - } - } - /// [`settle_roll_grant`] must check `withheld` BEFORE granting anything — without it, a /// wrong-shard `settle_loot_roll` call (the gateway fans a `ROLL_WON` settle to EVERY connected /// shard, since it does not know in advance which one holds the corpse) could steal an ordinary, diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs index f6210ebfd..7662bb2b7 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -10,37 +10,194 @@ use support::Standalone; const CORPSE: u64 = 5_090_001; const ITEM: u32 = 5_090_002; const VOTERS: &str = "[5090003,5090004,5090005]"; +const SOURCE: &str = r#"{"__identity__":"0x1"}"#; +const ZERO: &str = r#"{"__identity__":"0x0"}"#; + +struct Promotion { + corpse: u64, + slot: u8, + item: u32, + deadline: i64, + voters: String, + random_property_id: u32, + source: String, + source_roll_id: u64, +} + +impl Default for Promotion { + fn default() -> Self { + Self { + corpse: CORPSE, + slot: 0, + item: ITEM, + deadline: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as i64 + + 600_000_000, + voters: VOTERS.into(), + random_property_id: 117, + source: SOURCE.into(), + source_roll_id: 10, + } + } +} + +impl Promotion { + fn args(&self) -> Vec { + vec![ + "0".into(), + self.corpse.to_string(), + self.slot.to_string(), + self.item.to_string(), + "0".into(), + "0".into(), + self.deadline.to_string(), + self.voters.clone(), + self.random_property_id.to_string(), + self.source.clone(), + self.source_roll_id.to_string(), + ] + } + + fn send(&self, standalone: &Standalone) { + let args = self.args(); + standalone.assert_call( + "realm_loot_op", + &args.iter().map(String::as_str).collect::>(), + ); + } + + fn assert_refused(&self, standalone: &Standalone) { + let args = self.args(); + let output = standalone.call( + "realm_loot_op", + &args.iter().map(String::as_str).collect::>(), + ); + assert!(!output.status.success()); + let message = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(message.contains("loot:roll_unavailable"), "{message}"); + } +} + +fn fixture(name: &str) -> Standalone { + let mut standalone = Standalone::start(name); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone +} + +fn vote(standalone: &Standalone, voter: &str, kind: &str) { + standalone.assert_call( + "realm_loot_op", + &[ + "1", + &CORPSE.to_string(), + "0", + "0", + voter, + kind, + "0", + "[]", + "0", + ZERO, + "0", + ], + ); +} + +fn resolve(standalone: &Standalone) { + for voter in ["5090003", "5090004", "5090005"] { + vote(standalone, voter, "0"); + } + assert_no_active_roll(standalone); +} + +fn assert_no_active_roll(standalone: &Standalone) { + assert!( + standalone + .query_rows("SELECT * FROM game_loot_roll") + .is_empty(), + "replaying a resolved START must not recreate an active Loot Roll" + ); + assert!( + votes(standalone).is_empty(), + "resolved Loot Roll votes must stay deleted" + ); +} #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -fn concurrent_promotions_preserve_one_roll_and_its_votes() { - let mut standalone = Standalone::start("loot-roll-promotion"); +fn resolved_promotion_does_not_recreate_roll_or_votes() { + let mut standalone = fixture("loot-roll-replay"); + let mut promotion = Promotion::default(); + promotion.send(&standalone); + resolve(&standalone); + let receipt = standalone.query_rows("SELECT * FROM game_loot_roll_promotion_receipt"); + assert_eq!(receipt.len(), 1); + promotion.send(&standalone); + assert_no_active_roll(&standalone); + assert_eq!( + standalone.query_rows("SELECT * FROM game_loot_roll_promotion_receipt"), + receipt + ); + + // A normal publish retains the receipt after the active roll and votes have been cleaned up. standalone.publish_module(); - standalone.assert_call("claim_operator", &[]); - let deadline = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_micros() as i64 - + 600_000_000; + promotion.send(&standalone); + assert_no_active_roll(&standalone); + + promotion.source_roll_id = 11; + promotion.send(&standalone); + let active = standalone.query_rows("SELECT * FROM game_loot_roll"); + let active_votes = votes(&standalone); + assert_eq!(active.len(), 1); + assert_eq!(active_votes.len(), 3); + promotion.source_roll_id = 10; + promotion.send(&standalone); + assert_eq!( + standalone.query_rows("SELECT * FROM game_loot_roll"), + active + ); + assert_eq!(votes(&standalone), active_votes); + resolve(&standalone); + for older in [11, 10, 1] { + promotion.source_roll_id = older; + promotion.send(&standalone); + assert_no_active_roll(&standalone); + } + let retained = standalone.query_rows("SELECT * FROM game_loot_roll_promotion_receipt"); + assert_eq!(retained.len(), 1, "a later generation replaces its receipt"); + assert_eq!(retained[0]["id"], receipt[0]["id"]); + assert_eq!(retained[0]["source_roll_id"], "11"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn concurrent_promotions_preserve_one_roll_and_its_votes() { + let standalone = fixture("loot-roll-promotion"); + let mut promotion = Promotion::default(); let ready = Barrier::new(2); thread::scope(|threads| { for _ in 0..2 { threads.spawn(|| { ready.wait(); - promote(&standalone, CORPSE, 0, ITEM, deadline, VOTERS); + promotion.send(&standalone); }); } }); - let rolls = standalone.query_rows("SELECT * FROM game_loot_roll"); assert_eq!( rolls.len(), 1, - "concurrent START calls must share one live Loot Roll: {rolls:?}" + "concurrent START calls must share one live Loot Roll" ); 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"); + let initial_votes = votes(&standalone); assert_eq!(initial_votes.len(), 3); let mut voters: Vec<_> = initial_votes .iter() @@ -49,66 +206,140 @@ fn concurrent_promotions_preserve_one_roll_and_its_votes() { voters.sort_unstable(); assert_eq!(voters, ["5090003", "5090004", "5090005"]); for vote in initial_votes { - assert_eq!(&vote["roll_id"], roll_id); + assert_eq!(vote["roll_id"], rolls[0]["id"]); assert_eq!(vote["voted"], "false"); } - - standalone.assert_call( - "realm_loot_op", - &[ - "1", - &CORPSE.to_string(), - "0", - "0", - "5090003", - "1", - "0", - "[]", - "0", - ], - ); + vote(&standalone, "5090003", "1"); let voted = votes(&standalone); assert_eq!(voted.iter().filter(|v| v["voted"] == "true").count(), 1); - promote( - &standalone, - CORPSE, - 0, - ITEM + 1, - deadline + 60_000_000, - "[5090006]", - ); + promotion.item += 1; + promotion.random_property_id += 1; + promotion.deadline += 60_000_000; + promotion.voters = "[5090006]".into(); + promotion.send(&standalone); assert_eq!(standalone.query_rows("SELECT * FROM game_loot_roll"), rolls); assert_eq!(votes(&standalone), voted); - promote(&standalone, CORPSE, 1, ITEM, deadline, VOTERS); - promote(&standalone, CORPSE + 1, 0, ITEM, deadline, VOTERS); + // Sequence order belongs to each source/corpse/slot key, not to all promotions from a source. + promotion.voters = VOTERS.into(); + promotion.source_roll_id = 1; + promotion.slot = 1; + promotion.send(&standalone); + promotion.corpse += 1; + promotion.slot = 0; + promotion.send(&standalone); assert_eq!( standalone.query_rows("SELECT * FROM game_loot_roll").len(), 3 ); + assert_eq!(votes(&standalone).len(), 9); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn newer_generation_waits_without_consuming_its_receipt() { + let standalone = fixture("loot-roll-generation"); + let mut promotion = Promotion::default(); + promotion.send(&standalone); + let rolls = standalone.query_rows("SELECT * FROM game_loot_roll"); + let initial_votes = votes(&standalone); + let receipt = standalone.query_rows("SELECT * FROM game_loot_roll_promotion_receipt"); + promotion.source_roll_id += 1; + promotion.assert_refused(&standalone); + assert_eq!(standalone.query_rows("SELECT * FROM game_loot_roll"), rolls); + assert_eq!(votes(&standalone), initial_votes); + assert_eq!( + standalone.query_rows("SELECT * FROM game_loot_roll_promotion_receipt"), + receipt + ); + resolve(&standalone); + promotion.send(&standalone); + assert_eq!(votes(&standalone).len(), 3); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn source_namespaces_remain_independent_after_resolution() { + let standalone = fixture("loot-roll-source"); + let mut promotion = Promotion::default(); + promotion.send(&standalone); + resolve(&standalone); + promotion.source = r#"{"__identity__":"0x2"}"#.into(); + promotion.source_roll_id = 1; + promotion.send(&standalone); + resolve(&standalone); + promotion.source = SOURCE.into(); + promotion.source_roll_id = 10; + promotion.send(&standalone); + assert_no_active_roll(&standalone); assert_eq!( standalone - .query_rows("SELECT * FROM game_loot_roll_vote") + .query_rows("SELECT * FROM game_loot_roll_promotion_receipt") .len(), - 9 + 2 ); } -fn promote(standalone: &Standalone, corpse: u64, slot: u8, item: u32, deadline: i64, voters: &str) { - standalone.assert_call( - "realm_loot_op", - &[ - "0", - &corpse.to_string(), - &slot.to_string(), - &item.to_string(), - "0", - "0", - &deadline.to_string(), - voters, - "117", - ], +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn missing_promotion_identity_is_refused_without_rows() { + let standalone = fixture("loot-roll-missing-identity"); + for (source, source_roll_id) in [(ZERO, 10), (SOURCE, 0)] { + Promotion { + source: source.into(), + source_roll_id, + ..Promotion::default() + } + .assert_refused(&standalone); + } + assert_no_active_roll(&standalone); + assert!(standalone + .query_rows("SELECT * FROM game_loot_roll_promotion_receipt") + .is_empty()); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn staging_identity_survives_publish_and_old_cleanup_cannot_clear_a_later_roll() { + let mut source = fixture("loot-roll-staging"); + let realm = fixture("loot-roll-staging-realm"); + source.assert_call("debug_stage_loot_roll_fixture", &[]); + let first = source.query_rows("SELECT * FROM game_loot_roll").remove(0); + assert_eq!(first["random_property_id"], "117"); + let owner = source.query_rows("SELECT * FROM game_operator").remove(0); + assert_ne!( + first["promotion_source"], owner["identity"], + "source identity must not be the Owner Token identity" ); + assert!(!first["promotion_source"] + .trim_start_matches("0x") + .chars() + .all(|c| c == '0')); + let mut promotion = Promotion { + source: format!(r#"{{"__identity__":"{}"}}"#, first["promotion_source"]), + source_roll_id: first["id"].parse().unwrap(), + deadline: first["deadline_micros"].parse().unwrap(), + ..Promotion::default() + }; + promotion.send(&realm); + resolve(&realm); + source.publish_module(); + assert_eq!(source.query_rows("SELECT * FROM game_loot_roll")[0], first); + promotion.send(&realm); + assert_no_active_roll(&realm); + source.assert_call("clear_promoted_loot_roll", &[&first["id"]]); + assert_no_active_roll(&source); + source.assert_call("debug_stage_loot_roll_fixture", &[]); + let next = source.query_rows("SELECT * FROM game_loot_roll").remove(0); + assert!(next["id"].parse::().unwrap() > first["id"].parse::().unwrap()); + assert_eq!(next["promotion_source"], first["promotion_source"]); + promotion.source_roll_id = next["id"].parse().unwrap(); + promotion.deadline = next["deadline_micros"].parse().unwrap(); + promotion.send(&realm); + assert_eq!(votes(&realm).len(), 3); + source.assert_call("clear_promoted_loot_roll", &[&first["id"]]); + assert_eq!(source.query_rows("SELECT * FROM game_loot_roll")[0], next); + assert_eq!(votes(&source).len(), 3); } fn votes(standalone: &Standalone) -> Vec> { diff --git a/module/tests/xp_cap.rs b/module/tests/xp_cap.rs index e25d4712f..4878003ce 100644 --- a/module/tests/xp_cap.rs +++ b/module/tests/xp_cap.rs @@ -33,12 +33,13 @@ fn the_level_cap_discards_excess_xp_and_preserves_rested_xp_on_later_kills() { ); standalone.assert_sql("UPDATE game_character SET rested_xp = 1000 WHERE guid = 1"); + // SQL row order does not identify the nearest creature. Keep this kill target unique. + standalone.assert_sql("DELETE FROM game_creature_spawn WHERE entry = 51000"); + standalone.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); standalone.assert_call("debug_spawn_at_feet", &["1", "51000", "1.0"]); - let creature = standalone - .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") - .pop() - .expect("the fixture creature exists")["guid"] - .clone(); + let creatures = standalone.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000"); + assert_eq!(creatures.len(), 1, "the kill target must be unique"); + let creature = &creatures[0]["guid"]; standalone.assert_sql(&format!( "UPDATE game_world_entity SET level = 60 WHERE guid = {creature}" ));