Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lyracore-cli-rev
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ad2aa067bf5c9bf47c3c4034aaa3897c1eb6f502
d6601ec35592eb0f18c40408ba5113846f4640e8
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
33 changes: 33 additions & 0 deletions docs/loot-roll-promotion.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions gateway/src/stdb/bindings/debug_stage_loot_roll_fixture_reducer.rs
Original file line number Diff line number Diff line change
@@ -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<DebugStageLootRollFixtureArgs> 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<Result<(), String>, __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<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(DebugStageLootRollFixtureArgs {}, callback)
}
}
235 changes: 235 additions & 0 deletions gateway/src/stdb/bindings/game_loot_roll_promotion_receipt_table.rs
Original file line number Diff line number Diff line change
@@ -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<LootRollPromotionReceipt>,
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<super::RemoteTables> 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::<LootRollPromotionReceipt>("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<Item = LootRollPromotionReceipt> + '_ {
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<Item = LootRollPromotionReceipt> + '_ {
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<LootRollPromotionReceipt, u64>,
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::<u64>("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<LootRollPromotionReceipt> {
self.imp.find(col_val)
}
}

#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache
.get_or_make_table::<LootRollPromotionReceipt>("game_loot_roll_promotion_receipt");
_table.add_unique_constraint::<u64>("id", |row| &row.id);
}

#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<LootRollPromotionReceipt>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<LootRollPromotionReceipt>", "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<LootRollPromotionReceipt>;
}

impl game_loot_roll_promotion_receiptQueryTableAccess for __sdk::QueryTableAccessor {
fn game_loot_roll_promotion_receipt(
&self,
) -> __sdk::__query_builder::Table<LootRollPromotionReceipt> {
__sdk::__query_builder::Table::new("game_loot_roll_promotion_receipt")
}
}
Loading