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
18 changes: 12 additions & 6 deletions module/src/loot/rolls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
115 changes: 115 additions & 0 deletions module/tests/loot_roll_promotion.rs
Original file line number Diff line number Diff line change
@@ -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<BTreeMap<String, String>> {
let mut rows = standalone.query_rows("SELECT * FROM game_loot_roll_vote");
rows.sort_by(|a, b| a["id"].cmp(&b["id"]));
rows
}