From 94c5d5e12ac3d9d45727e78b8977392230603ac3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 09:01:19 +0200 Subject: [PATCH] fix(loot): keep concurrent promotions on one live roll --- module/src/loot/rolls.rs | 18 +++-- module/tests/loot_roll_promotion.rs | 115 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 module/tests/loot_roll_promotion.rs diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index b96217684..18b30c0f2 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -328,11 +328,9 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro } } -/// Insert one [`LootRoll`] row + one [`LootRollVote`] per recipient (`voted=false`), with NO event -/// push. The shared core of [`start_roll`] (kill time — this database keeps the roll unsharded, or -/// stages it for promotion sharded) and `realm_loot_op`'s `loot_op::START` arm (this -/// database IS realm-core, RE-CREATING a roll a world shard already announced locally; pushing -/// `ROLL_START` again here would double the popup). Returns the new roll's id. +/// 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. fn insert_roll_rows( ctx: &ReducerContext, corpse_guid: u64, @@ -341,7 +339,15 @@ fn insert_roll_rows( recipients: &[u64], deadline_micros: i64, ) -> u64 { - let roll = ctx.db.game_loot_roll().insert(LootRoll { + let rolls = ctx.db.game_loot_roll(); + if let Some(existing) = rolls + .by_corpse() + .filter(&corpse_guid) + .find(|r| r.slot == slot) + { + return existing.id; + } + let roll = rolls.insert(LootRoll { id: 0, corpse_guid, slot, diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs new file mode 100644 index 000000000..5490c6943 --- /dev/null +++ b/module/tests/loot_roll_promotion.rs @@ -0,0 +1,115 @@ +mod support; + +use std::collections::BTreeMap; +use std::sync::Barrier; +use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; + +use support::Standalone; + +const CORPSE: u64 = 5_090_001; +const ITEM: u32 = 5_090_002; +const VOTERS: &str = "[5090003,5090004,5090005]"; + +#[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"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + let deadline = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as i64 + + 600_000_000; + let ready = Barrier::new(2); + thread::scope(|threads| { + for _ in 0..2 { + threads.spawn(|| { + ready.wait(); + promote(&standalone, CORPSE, 0, ITEM, deadline, VOTERS); + }); + } + }); + + 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:?}" + ); + let roll_id = &rolls[0]["id"]; + let initial_votes = standalone.query_rows("SELECT * FROM game_loot_roll_vote"); + assert_eq!(initial_votes.len(), 3); + let mut voters: Vec<_> = initial_votes + .iter() + .map(|v| v["voter_guid"].as_str()) + .collect(); + voters.sort_unstable(); + assert_eq!(voters, ["5090003", "5090004", "5090005"]); + for vote in initial_votes { + assert_eq!(&vote["roll_id"], roll_id); + assert_eq!(vote["voted"], "false"); + } + + standalone.assert_call( + "realm_loot_op", + &[ + "1", + &CORPSE.to_string(), + "0", + "0", + "5090003", + "1", + "0", + "[]", + ], + ); + 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]", + ); + 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); + assert_eq!( + standalone.query_rows("SELECT * FROM game_loot_roll").len(), + 3 + ); + assert_eq!( + standalone + .query_rows("SELECT * FROM game_loot_roll_vote") + .len(), + 9 + ); +} + +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, + ], + ); +} + +fn votes(standalone: &Standalone) -> Vec> { + let mut rows = standalone.query_rows("SELECT * FROM game_loot_roll_vote"); + rows.sort_by(|a, b| a["id"].cmp(&b["id"])); + rows +}