diff --git a/module/src/bridge.rs b/module/src/bridge.rs index 8ede2c62e..d88e300ee 100644 --- a/module/src/bridge.rs +++ b/module/src/bridge.rs @@ -17,7 +17,7 @@ use lyracore_shared::group::COMMAND_RESULT_WINDOW_MICROS; use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; #[cfg(feature = "debug_reducers")] -use crate::{game_group, game_group_member, game_world_entity}; +use crate::{game_group, game_world_entity}; const COMMAND_LIFETIME_MICROS: i64 = 30_000_000; const CLAIM_LEASE_MICROS: i64 = 2_000_000; @@ -696,17 +696,12 @@ fn fixture_command_apply_after_authority( .group_id() .find(member.group_id) .ok_or_else(|| CommandOutcome::NotMember.tag().to_string())?; - let members: Vec<_> = ctx - .db - .game_group_member() - .by_group() - .filter(&member.group_id) - .take(lyracore_shared::group::RAID_MAX_MEMBERS + 1) - .map(|row| row.character_guid) - .collect(); - if members.len() > lyracore_shared::group::RAID_MAX_MEMBERS { - return Err(CommandOutcome::StalePartyMirror.tag().to_string()); - } + let members: Vec<_> = + crate::group::bounded_roster(ctx, member.group_id, crate::group::group_kind_of(&group)) + .ok_or_else(|| CommandOutcome::StalePartyMirror.tag().to_string())? + .into_iter() + .map(|row| row.character_guid) + .collect(); crate::group::admit_party_command_authority( ctx, member.group_id, diff --git a/module/src/group.rs b/module/src/group.rs index 3e7bc720a..32e4d0877 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -738,7 +738,8 @@ pub fn companion_target_facts( /// Recheck the Gateway-certified authority projection and the bot Gate in the target transaction. /// `None` means Package application may proceed; `Some` is a terminal typed Refusal. Companion -/// Orders cover the whole Raid: the bound is the Raid cap, and a longer list is a damaged mirror. +/// Orders cover the whole Raid: the bound is the member cap of the Group's kind, and a longer list +/// is a damaged mirror. pub(crate) fn admit_party_command( ctx: &ReducerContext, admitted: &crate::bridge::AdmittedClientCommand, @@ -765,19 +766,14 @@ pub(crate) fn admit_party_command( let Some(group) = ctx.db.game_group().group_id().find(member.group_id) else { return Some(CommandOutcome::StalePartyMirror); }; - let mut local: Vec<_> = ctx - .db - .game_group_member() - .by_group() - .filter(&member.group_id) - .take(RAID_MAX_MEMBERS + 1) - .map(|row| row.character_guid) - .collect(); + let Some(roster) = bounded_roster(ctx, member.group_id, group_kind_of(&group)) else { + return Some(CommandOutcome::StalePartyMirror); + }; + let mut local: Vec<_> = roster.into_iter().map(|row| row.character_guid).collect(); let mut certified = admitted.members.clone(); local.sort_unstable(); certified.sort_unstable(); - if local.len() > RAID_MAX_MEMBERS - || member.group_id != admitted.group_id + if member.group_id != admitted.group_id || group.leader_guid != admitted.leader_guid || admitted.leader_guid != admitted.issuer_guid || local != certified @@ -828,16 +824,8 @@ pub(crate) fn has_known_party_member_in_partition( { return Err(PartyFactsUnavailableReason::MissingGroup); } - let members: Vec<_> = ctx - .db - .game_group_member() - .by_group() - .filter(&member.group_id) - .take(GROUP_MAX_MEMBERS + 1) - .collect(); - if members.len() > GROUP_MAX_MEMBERS { - return Err(PartyFactsUnavailableReason::FightLimit); - } + let members = bounded_roster(ctx, member.group_id, GroupKind::Party) + .ok_or(PartyFactsUnavailableReason::FightLimit)?; Ok(members.into_iter().any(|candidate| { candidate.character_guid != character_guid && ctx @@ -885,8 +873,9 @@ fn known_party_partition( /// entity on this Shard, so those facts are nullable. /// /// Facts cover every member of the Group, Raid included, whatever the Subgroup. Reads stop with -/// `FightLimit` above 40 members (only a damaged mirror holds more), 24 incoming melee or -/// threat-source rows for one member, one pending cast for one member, or 24 aggregate enemy GUIDs. +/// `FightLimit` above the member cap of the Group's kind (only a damaged mirror holds more), 24 +/// incoming melee or threat-source rows for one member, one pending cast for one member, or 24 +/// aggregate enemy GUIDs. /// Each retained enemy permits 80 threat sources (a full Raid and one pet each), 64 control auras, /// and three effects on a pending spell. A missing parent Group stops with `MissingGroup`; neither /// failure returns facts selected from an arbitrary prefix. @@ -907,19 +896,12 @@ pub fn party_facts( group_id: member.group_id, reason: PartyFactsUnavailableReason::MissingGroup, })?; - let members: Vec<_> = ctx - .db - .game_group_member() - .by_group() - .filter(&member.group_id) - .take(RAID_MAX_MEMBERS + 1) - .collect(); - if members.len() > RAID_MAX_MEMBERS { - return Err(PartyFactsUnavailable { - group_id: member.group_id, - reason: PartyFactsUnavailableReason::FightLimit, - }); - } + let unavailable = || PartyFactsUnavailable { + group_id: member.group_id, + reason: PartyFactsUnavailableReason::FightLimit, + }; + let members = + bounded_roster(ctx, member.group_id, group_kind_of(&group)).ok_or_else(unavailable)?; let members: Vec<_> = members .into_iter() .map(|member| { @@ -957,10 +939,6 @@ pub fn party_facts( let mut pending_control_targets = std::collections::BTreeMap::new(); const ENEMY_LIMIT: usize = 24; const THREAT_SOURCE_LIMIT: usize = RAID_MAX_MEMBERS * 2; - let unavailable = || PartyFactsUnavailable { - group_id: member.group_id, - reason: PartyFactsUnavailableReason::FightLimit, - }; for guid in &party_guids { if let Some(attack) = melee.attacker_guid().find(*guid) { enemy_guids.insert(attack.target_guid); @@ -1131,6 +1109,25 @@ pub(crate) fn members_of(ctx: &ReducerContext, group_id: u64) -> Vec Option> { + let cap = kind.member_cap(); + let roster: Vec<_> = ctx + .db + .game_group_member() + .by_group() + .filter(&group_id) + .take(cap + 1) + .collect(); + (roster.len() <= cap).then_some(roster) +} + /// Who hears a party line: every member of the speaker's Group, the speaker included, in a Party. /// In a Raid it narrows to the speaker's own Subgroup (cm:ChatHandler.cpp:302-332, /// cm:Group.cpp:777-788). Nobody's ignore list filters it. Read from this database's membership, @@ -2144,7 +2141,8 @@ fn next_group_revision(current: Option, was_active: bool) -> u64 { /// each Character whose membership the push changes: the mirror is how this Shard learns of it. /// /// `group_kind` is the [`GroupKind`] byte and `raid_slots[n]` is `members[n]`'s [`RaidSlot`] byte. -/// A length mismatch, an unknown kind or an invalid slot refuses the whole push. +/// A length mismatch, an unknown kind, more members than the kind holds, or an invalid slot refuses +/// the whole push. #[reducer] #[allow(clippy::too_many_arguments)] // The roster and initiating Actor are the reducer wire contract. pub fn sync_group_mirror( @@ -2391,6 +2389,13 @@ fn mirror_kind_and_slots( ) -> Result<(GroupKind, std::collections::BTreeMap), String> { let kind = GroupKind::from_wire(group_kind) .ok_or_else(|| format!("group mirror has unknown group kind {group_kind}"))?; + if members.len() > kind.member_cap() { + return Err(format!( + "group mirror has {} members for a group that holds at most {}", + members.len(), + kind.member_cap() + )); + } if raid_slots.len() != members.len() { return Err(format!( "group mirror has {} Raid Slots for {} members", @@ -2445,8 +2450,8 @@ fn mirror_slots_differ( } /// Acknowledged Realm-core party authority read for one companion command attempt. Companion -/// Orders cover the whole Raid: both lists are bounded at the Raid cap, and a longer list is a -/// damaged mirror. +/// Orders cover the whole Raid: both lists are bounded at the member cap of the Group's kind, and a +/// longer list is a damaged mirror. #[reducer] pub fn admit_party_command_authority( ctx: &ReducerContext, @@ -2457,38 +2462,35 @@ pub fn admit_party_command_authority( mut expected_members: Vec, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; + let stale = || { + crate::bridge::CommandOutcome::StalePartyMirror + .tag() + .to_string() + }; let group = ctx .db .game_group() .group_id() .find(group_id) - .ok_or_else(|| { - crate::bridge::CommandOutcome::StalePartyMirror - .tag() - .to_string() - })?; + .ok_or_else(stale)?; if group.leader_guid != leader_guid { return Err(crate::bridge::CommandOutcome::NotLeader.tag().to_string()); } - let members = ctx.db.game_group_member(); - let mut current_members: Vec<_> = members - .by_group() - .filter(&group_id) - .take(RAID_MAX_MEMBERS + 1) + let kind = group_kind_of(&group); + if expected_members.len() > kind.member_cap() { + return Err(stale()); + } + let mut current_members: Vec<_> = bounded_roster(ctx, group_id, kind) + .ok_or_else(stale)? + .into_iter() .map(|member| member.character_guid) .collect(); - if current_members.len() > RAID_MAX_MEMBERS || expected_members.len() > RAID_MAX_MEMBERS { - return Err(crate::bridge::CommandOutcome::StalePartyMirror - .tag() - .to_string()); - } current_members.sort_unstable(); expected_members.sort_unstable(); if current_members != expected_members { - return Err(crate::bridge::CommandOutcome::StalePartyMirror - .tag() - .to_string()); + return Err(stale()); } + let members = ctx.db.game_group_member(); for guid in [leader_guid, bot_guid, authority_member_guid] .into_iter() .filter(|guid| *guid != 0) diff --git a/module/tests/raid_convert.rs b/module/tests/raid_convert.rs index ad5c9804c..2c9386a43 100644 --- a/module/tests/raid_convert.rs +++ b/module/tests/raid_convert.rs @@ -198,25 +198,30 @@ fn a_party_still_caps_at_five() { .all(|row| row["raid_slot"] == "0")); } -fn partition(guid: u64, membership_revision: u64) -> serde_json::Value { +fn partition(group_id: u64, guid: u64) -> serde_json::Value { serde_json::json!({ - "character_guid": guid, "group_id": 900, - "membership_revision": membership_revision, "member_active": true, + "character_guid": guid, "group_id": group_id, + "membership_revision": 1, "member_active": true, "map_id": 0, "instance_id": 0, "locator_revision": 0, "state": {"unknown": []}, }) } -fn mirror_args(revision: u64, kind: u8, slots: &[u8]) -> Vec { +/// Arguments for one `sync_group_mirror` push. The first member leads. +fn mirror_args(group_id: u64, guids: &[u64], revision: u64, kind: u8, slots: &[u8]) -> Vec { + let partitions: Vec<_> = guids + .iter() + .map(|&guid| partition(group_id, guid)) + .collect(); vec![ - "900".to_string(), - "10".to_string(), + group_id.to_string(), + guids[0].to_string(), "3".to_string(), "2".to_string(), "0".to_string(), - "[10,11]".to_string(), + serde_json::to_string(guids).unwrap(), actor("0"), - serde_json::to_string(&[partition(10, 1), partition(11, 2)]).unwrap(), + serde_json::to_string(&partitions).unwrap(), revision.to_string(), kind.to_string(), serde_json::to_string(slots).unwrap(), @@ -224,13 +229,30 @@ fn mirror_args(revision: u64, kind: u8, slots: &[u8]) -> Vec { } fn mirror_refusal(node: &Standalone, revision: u64, kind: u8, slots: &[u8]) -> String { - let args = mirror_args(revision, kind, slots); + let args = mirror_args(900, &[10, 11], revision, kind, slots); let args: Vec<_> = args.iter().map(String::as_str).collect(); failure_text(node, "sync_group_mirror", &args) } fn mirror(node: &Standalone, revision: u64, kind: u8, slots: &[u8]) { - let args = mirror_args(revision, kind, slots); + let args = mirror_args(900, &[10, 11], revision, kind, slots); + let args: Vec<_> = args.iter().map(String::as_str).collect(); + node.assert_call("sync_group_mirror", &args); +} + +/// The first push of a Group of `guids`, Raid members five to a Subgroup. +fn first_push_args(group_id: u64, guids: &[u64], kind: GroupKind) -> Vec { + let slots: Vec = (0..guids.len()) + .map(|index| match kind { + GroupKind::Party => 0, + GroupKind::Raid => (index / 5) as u8, + }) + .collect(); + mirror_args(group_id, guids, 1, kind.wire(), &slots) +} + +fn mirror_group(node: &Standalone, group_id: u64, guids: &[u64], kind: GroupKind) { + let args = first_push_args(group_id, guids, kind); let args: Vec<_> = args.iter().map(String::as_str).collect(); node.assert_call("sync_group_mirror", &args); } @@ -313,4 +335,85 @@ fn a_raid_past_five_certifies_a_companion_order_for_any_member() { text.contains("NotLeader"), "a Raid member who is not the leader: {text}" ); + + realm.assert_call("debug_spawn_player_entity", &["1"]); + let outcome = applied_order_outcome(&realm, group_id.as_str(), &all_members); + assert_eq!( + outcome, "(suppressed = ())", + "a Raid of ten passes the target roster bound" + ); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_world_shard_mirror_refuses_a_party_past_five() { + let mut shard = Standalone::start("raid-convert-party-cap"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + + let args = first_push_args(900, &[1, 2, 3, 4, 5, 6], GroupKind::Party); + let args: Vec<_> = args.iter().map(String::as_str).collect(); + let text = failure_text(&shard, "sync_group_mirror", &args); + assert!( + text.contains("6 members for a group that holds at most 5"), + "{text}" + ); + assert!(shard + .query_rows("SELECT * FROM game_group WHERE group_id = 900") + .is_empty()); + + mirror_group(&shard, 901, &[1, 2, 3, 4, 5], GroupKind::Party); + mirror_group(&shard, 902, &[11, 12, 13, 14, 15, 16], GroupKind::Raid); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_party_mirror_past_five_is_stale_at_both_companion_order_readers() { + let mut realm = Standalone::start("raid-convert-damaged-party"); + realm.publish_module(); + realm.assert_call("claim_operator", &[]); + realm.assert_call("debug_spawn_player_entity", &["1"]); + // The writer refuses a Party past five, so plant the damage: a Raid of six turned into a Party. + mirror_group(&realm, 900, &[1, 2, 3, 4, 5, 6], GroupKind::Raid); + realm.assert_sql("UPDATE game_group SET group_type = 0 WHERE group_id = 900"); + + // The certified list matches the six rows, so these refuse on the Party cap. + let party = serde_json::to_string(&[1, 2, 3, 4, 5, 6]).unwrap(); + let text = failure_text( + &realm, + "admit_party_command_authority", + &["900", "1", "2", "3", party.as_str()], + ); + assert!(text.contains("StalePartyMirror"), "{text}"); + + let outcome = applied_order_outcome(&realm, "900", &[1, 2, 3, 4, 5, 6]); + assert_eq!(outcome, "(stalePartyMirror = ())"); +} + +const SOURCE_IDENTITY: &str = "0x0000000000000000000000000000000000000000000000000000000000000001"; + +/// Apply a Companion Order for the fixture bot (Character 1) as `leader`, certifying `members` +/// for `group_id`, then return the outcome the Command Receipt recorded. +fn applied_order_outcome(node: &Standalone, group_id: &str, members: &[u64]) -> String { + let members = serde_json::to_string(members).unwrap(); + let identity = format!("\"{SOURCE_IDENTITY}\""); + node.assert_call( + "apply_admitted_party_command", + &[ + identity.as_str(), + "1", + "1", + "1", + group_id, + "1", + members.as_str(), + "0", + "1", + "0", + "0", + "9223372036854775807", + "9223372036854775807", + ], + ); + node.query_rows("SELECT outcome FROM game_party_command_receipt")[0]["outcome"].clone() }