diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index cb457d5f3..15e6e07b9 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -147,6 +147,8 @@ pub mod event_kind { pub const TARGET_ICON_LIST: u8 = 24; pub const MINIMAP_PING: u8 = 25; pub const RANDOM_ROLL: u8 = 26; + /// Empty `SMSG_GROUP_UNINVITE` to a member kicked from a surviving Group, before [`DESTROYED`]. + pub const UNINVITED: u8 = 31; } pub mod realm_op { diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index d17fa4a6a..51eff190f 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -305,6 +305,37 @@ pub fn acceptance( Ok(()) } +impl AcceptanceRefusal { + const LEVEL_PREFIX: &'static str = "requires level "; + + pub fn text(self, min_level: u32) -> String { + match self { + Self::Level => format!("{}{min_level}", Self::LEVEL_PREFIX), + Self::Race => "quest not available to your race".to_string(), + Self::Class => "quest not available to your class".to_string(), + Self::Prerequisite => "must complete the prerequisite quest first".to_string(), + Self::AlreadyTaken => "already on or completed that quest".to_string(), + } + } + + /// The inverse of [`Self::text`]. + pub fn parse(text: &str) -> Option { + if text.starts_with(Self::LEVEL_PREFIX) { + return Some(Self::Level); + } + [ + Self::Race, + Self::Class, + Self::Prerequisite, + Self::AlreadyTaken, + ] + .into_iter() + .find(|refusal| refusal.text(0) == text) + } +} + +pub const QUEST_LOG_FULL_TEXT: &str = "quest log is full"; + pub fn base_reward_xp(explicit_xp: u32, reward_money_max_level: u32, quest_level: u32) -> u32 { if explicit_xp > 0 { explicit_xp @@ -319,6 +350,20 @@ pub fn base_reward_xp(explicit_xp: u32, reward_money_max_level: u32, quest_level mod rule_tests { use super::*; + #[test] + fn acceptance_refusal_text_parses_back() { + for refusal in [ + AcceptanceRefusal::Level, + AcceptanceRefusal::Race, + AcceptanceRefusal::Class, + AcceptanceRefusal::Prerequisite, + AcceptanceRefusal::AlreadyTaken, + ] { + assert_eq!(AcceptanceRefusal::parse(&refusal.text(12)), Some(refusal)); + } + assert_eq!(AcceptanceRefusal::parse(QUEST_LOG_FULL_TEXT), None); + } + #[test] fn completion_reads_each_objective_kind_from_its_authority() { let objectives = [ diff --git a/gateway/src/codec/quest.rs b/gateway/src/codec/quest.rs index 44caf6574..02cef6c62 100644 --- a/gateway/src/codec/quest.rs +++ b/gateway/src/codec/quest.rs @@ -142,6 +142,28 @@ pub fn build_questupdate_failedtimer(quest_id: u32) -> SMSG_QUESTUPDATE_FAILEDTI SMSG_QUESTUPDATE_FAILEDTIMER { quest_id } } +/// `SMSG_QUESTGIVER_QUEST_INVALID` (0x018F): reason u32. +pub fn build_questgiver_quest_invalid( + reason: wow_world_messages::vanilla::QuestFailedReason, +) -> wow_world_messages::vanilla::SMSG_QUESTGIVER_QUEST_INVALID { + wow_world_messages::vanilla::SMSG_QUESTGIVER_QUEST_INVALID { msg: reason } +} + +/// `SMSG_QUESTGIVER_QUEST_FAILED` (0x0192): quest id u32, reason u32. +pub fn build_questgiver_quest_failed( + quest_id: u32, + reason: wow_world_messages::vanilla::QuestFailedReason, +) -> wow_world_messages::vanilla::SMSG_QUESTGIVER_QUEST_FAILED { + wow_world_messages::vanilla::SMSG_QUESTGIVER_QUEST_FAILED { quest_id, reason } +} + +/// `SMSG_QUESTUPDATE_COMPLETE` (0x0198): quest id u32. +pub fn build_questupdate_complete( + quest_id: u32, +) -> wow_world_messages::vanilla::SMSG_QUESTUPDATE_COMPLETE { + wow_world_messages::vanilla::SMSG_QUESTUPDATE_COMPLETE { quest_id } +} + pub fn build_quest_push_result(member_guid: u64, code: u8) -> MSG_QUEST_PUSH_RESULT { MSG_QUEST_PUSH_RESULT { guid: Guid::new(member_guid), @@ -716,4 +738,42 @@ mod tests { } } +#[cfg(test)] +mod refusal_message_tests { + use super::*; + use wow_world_messages::vanilla::{QuestFailedReason, ServerMessage}; + + fn frame(message: impl ServerMessage) -> Vec { + let mut bytes = Vec::new(); + message.write_unencrypted_server(&mut bytes).unwrap(); + bytes + } + + #[test] + fn quest_invalid_is_opcode_018f_with_one_reason_word() { + let bytes = frame(build_questgiver_quest_invalid( + QuestFailedReason::QuestFailedLowLevel, + )); + assert_eq!(bytes, [0x00, 0x06, 0x8F, 0x01, 0x01, 0x00, 0x00, 0x00]); + } + + #[test] + fn quest_failed_is_opcode_0192_with_quest_then_reason() { + let bytes = frame(build_questgiver_quest_failed( + 0x0102, + QuestFailedReason::QuestFailedInventoryFull, + )); + assert_eq!( + bytes, + [0x00, 0x0A, 0x92, 0x01, 0x02, 0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00] + ); + } + + #[test] + fn quest_update_complete_is_opcode_0198_with_the_quest_id() { + let bytes = frame(build_questupdate_complete(0x0102)); + assert_eq!(bytes, [0x00, 0x06, 0x98, 0x01, 0x02, 0x01, 0x00, 0x00]); + } +} + pub(crate) mod subscriptions; diff --git a/gateway/src/codec/quest/subscriptions.rs b/gateway/src/codec/quest/subscriptions.rs index 78770cb8d..34c934ac4 100644 --- a/gateway/src/codec/quest/subscriptions.rs +++ b/gateway/src/codec/quest/subscriptions.rs @@ -57,6 +57,19 @@ pub(crate) fn quest_update_feedback( ))) }) .collect::>(); + let explored = objectives.iter().any(|&(kind, index, _, required)| { + let count = |counts: &[u32]| counts.get(index as usize).copied().unwrap_or(0); + kind == lyracore_shared::quest::objective_kind::EXPLORE_AREATRIGGER + && count(&old.counts) < required + && count(&row.counts) >= required + }); + if explored { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_QUESTUPDATE_COMPLETE(codec::build_questupdate_complete( + row.quest_entry, + )), + )); + } if !old.failed && row.failed { outbound.push(Outbound::One( ServerOpcodeMessage::SMSG_QUESTUPDATE_FAILEDTIMER( @@ -145,7 +158,7 @@ crate::stdb::relays::declare_relays! { delete(Snapshot) => |context, _event, row| quest_deleted(&context.view, &context.coord, context.shard, row); } game_character_quest_event_credit [false] { - insert(Snapshot) => |context, _event, row| quest_credit_changed(&context.view, &context.coord, context.shard, row); + insert(Live) => |context, _event, row| quest_credit_granted(&context.view, &context.coord, context.shard, row); update(Snapshot) => |context, _event, _old, row| quest_credit_changed(&context.view, &context.coord, context.shard, row); delete(Snapshot) => |context, _event, row| quest_credit_changed(&context.view, &context.coord, context.shard, row); } @@ -156,6 +169,36 @@ crate::stdb::relays::declare_relays! { } } +/// `Live` insert: a login replay of old credits stays silent. +fn quest_credit_granted( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &CharacterQuestEventCredit, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (coord, quest_entry) = (coord.clone(), row.quest_entry); + enqueue(viewer, move |viewer| { + let guard = coord.0.coord(); + event_credit_packets( + quest_entry, + quest_log_outbound(&guard.conn.db, viewer.self_guid), + ) + }); +} + +pub(crate) fn event_credit_packets(quest_entry: u32, quest_log: Option) -> Vec { + let mut outbound = vec![Outbound::One( + ServerOpcodeMessage::SMSG_QUESTUPDATE_COMPLETE(codec::build_questupdate_complete( + quest_entry, + )), + )]; + outbound.extend(quest_log); + outbound +} + fn quest_credit_changed( view: &WorldView, coord: &Coordinator, diff --git a/gateway/src/codec/quest/subscriptions/audience_tests.rs b/gateway/src/codec/quest/subscriptions/audience_tests.rs index c15c615f4..ec93f595d 100644 --- a/gateway/src/codec/quest/subscriptions/audience_tests.rs +++ b/gateway/src/codec/quest/subscriptions/audience_tests.rs @@ -40,3 +40,47 @@ fn quest_update_orders_kills_then_timer_then_full_log() { ] )); } + +#[test] +fn event_credit_announces_completion_before_the_full_log() { + let outbound = event_credit_packets( + 77, + Some(Outbound::Raw { + opcode: 1, + body: vec![0], + }), + ); + + match outbound.as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_COMPLETE(complete)), Outbound::Raw { .. }] => + { + assert_eq!(complete.quest_id, 77) + } + _ => panic!("expected completion then the quest log"), + } +} + +#[test] +fn a_finished_explore_objective_announces_completion_once() { + let old = CharacterQuest { + id: 1, + character_guid: 9001, + owner_identity: identity(1), + quest_entry: 77, + counts: vec![0], + rewarded: false, + deadline_micros: 0, + failed: false, + }; + let done = CharacterQuest { + counts: vec![1], + ..old.clone() + }; + let objectives = [(3, 0, 555, 1)]; + + assert!(matches!( + quest_update_feedback(&old, &done, &objectives).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_COMPLETE(c))] if c.quest_id == 77 + )); + assert!(quest_update_feedback(&done, &done, &objectives).is_empty()); +} diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index 84b69dee5..de5f6f77c 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -73,6 +73,7 @@ pub(crate) fn group_event_outbound< ), ))), group_kind::DESTROYED => Some(ServerOpcodeMessage::SMSG_GROUP_DESTROYED), + group_kind::UNINVITED => Some(ServerOpcodeMessage::SMSG_GROUP_UNINVITE), group_kind::SET_LEADER => other_character_name(store, row, "SET_LEADER").map(|name| { ServerOpcodeMessage::SMSG_GROUP_SET_LEADER(Box::new(codec::build_group_set_leader( name, diff --git a/gateway/src/stdb/relays.rs b/gateway/src/stdb/relays.rs index 594a25da6..1b9a092e8 100644 --- a/gateway/src/stdb/relays.rs +++ b/gateway/src/stdb/relays.rs @@ -242,6 +242,7 @@ mod tests { BTreeSet::from([ "game_addon_message", "game_auction_notice", + "game_character_quest_event_credit", "game_chat_channel_notice_event", "game_duel_event", "game_group_event", diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 049f1457c..5db8be631 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -48,6 +48,44 @@ fn ignore_refusal(result: Result<()>, operation: &str, account_id: u64) -> Resul } } +/// vmangos closes the gossip after every accept refusal, so the quest message is followed by it. +fn accept_refusal_outbound(quest_id: u32, reason: &str) -> Vec { + let mut outbound = accept_refusal_message(quest_id, reason); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); + outbound +} + +fn accept_refusal_message(quest_id: u32, reason: &str) -> Vec { + use lyracore_shared::quest::{AcceptanceRefusal, QUEST_LOG_FULL_TEXT}; + use wow_world_messages::vanilla::QuestFailedReason as Reason; + if reason == QUEST_LOG_FULL_TEXT { + return vec![Outbound::One(ServerOpcodeMessage::SMSG_QUESTLOG_FULL)]; + } + if let Some(refusal) = AcceptanceRefusal::parse(reason) { + let reason = match refusal { + AcceptanceRefusal::Level => Reason::QuestFailedLowLevel, + AcceptanceRefusal::Race => Reason::QuestFailedWrongRace, + AcceptanceRefusal::AlreadyTaken => Reason::QuestAlreadyOn, + AcceptanceRefusal::Class | AcceptanceRefusal::Prerequisite => Reason::DontHaveReq, + }; + return vec![Outbound::One( + ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_INVALID( + codec::build_questgiver_quest_invalid(reason), + ), + )]; + } + if lyracore_shared::item::ItemRefusal::parse_tag(reason) + == Some(lyracore_shared::item::ItemRefusal::InventoryFull) + { + return vec![Outbound::One( + ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_FAILED( + codec::build_questgiver_quest_failed(quest_id, Reason::QuestFailedInventoryFull), + ), + )]; + } + Vec::new() +} + pub(crate) fn quest_giver_menu( store: &St, giver: u64, @@ -210,12 +248,26 @@ impl ProtocolFamily for Quest { Ok(ProtocolReply::from(outbound)) } ClientOpcodeMessage::CMSG_QUESTGIVER_ACCEPT_QUEST(a) => { - ignore_refusal( - store.accept_quest(actor, a.guid.guid(), a.quest_id), - "accept_quest", - session.account_id, - )?; - Ok(ProtocolReply::from(Vec::new())) + match store.accept_quest(actor, a.guid.guid(), a.quest_id) { + Ok(()) => Ok(ProtocolReply::default()), + Err(e) => match classify(&e) { + DurableFailure::Refusal { reason } => { + log::debug!( + "world: accept_quest refused (account {}): {e}", + session.account_id + ); + Ok(ProtocolReply::from(accept_refusal_outbound( + a.quest_id, reason, + ))) + } + DurableFailure::TransportLoss => Err(e), + }, + } + } + ClientOpcodeMessage::CMSG_QUESTGIVER_CANCEL => { + Ok(ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE, + )])) } ClientOpcodeMessage::CMSG_QUESTGIVER_COMPLETE_QUEST(c) => { quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) @@ -771,16 +823,88 @@ mod tests { ); } - #[test] - fn a_refused_accept_answers_nothing_and_does_not_end_the_session() { + fn refused_accept(reason: &str) -> Vec { let actions = InMemoryQuestActions { - accept_error: Some("quest requires level 10".into()), + accept_error: Some(reason.into()), ..loaded_quest() }; + outbound(run_quest(&actions, session(), accept(GIVER, QUEST)).unwrap()) + } - let batch = outbound(run_quest(&actions, session(), accept(GIVER, QUEST)).unwrap()); + #[test] + fn a_refused_accept_names_the_unmet_requirement_to_the_client() { + use wow_world_messages::vanilla::QuestFailedReason as Reason; + for (text, expected) in [ + ("requires level 10", Reason::QuestFailedLowLevel), + ( + "quest not available to your race", + Reason::QuestFailedWrongRace, + ), + ("quest not available to your class", Reason::DontHaveReq), + ( + "must complete the prerequisite quest first", + Reason::DontHaveReq, + ), + ("already on or completed that quest", Reason::QuestAlreadyOn), + ] { + match refused_accept(text).as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_INVALID(m)), Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)] => + { + assert_eq!(m.msg, expected, "{text}") + } + other => panic!("{text}: unexpected {} replies", other.len()), + } + } + } - assert!(batch.is_empty()); + #[test] + fn a_refused_accept_with_a_full_log_answers_quest_log_full() { + assert!(matches!( + refused_accept(lyracore_shared::quest::QUEST_LOG_FULL_TEXT).as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_QUESTLOG_FULL), + Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE) + ] + )); + } + + #[test] + fn a_refused_accept_with_full_bags_answers_quest_failed_with_inventory_full() { + match refused_accept("item:inventory_full").as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_FAILED(m)), Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)] => + { + assert_eq!(m.quest_id, QUEST); + assert_eq!( + m.reason, + wow_world_messages::vanilla::QuestFailedReason::QuestFailedInventoryFull + ); + } + _ => panic!("expected SMSG_QUESTGIVER_QUEST_FAILED"), + } + } + + #[test] + fn a_refused_accept_for_a_giver_gate_only_closes_the_gossip() { + assert!(matches!( + refused_accept("quest giver is out of range").as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)] + )); + } + + #[test] + fn cancelling_the_quest_window_closes_the_gossip() { + let batch = outbound( + run_quest( + &loaded_quest(), + session(), + ClientOpcodeMessage::CMSG_QUESTGIVER_CANCEL, + ) + .unwrap(), + ); + assert!(matches!( + batch.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)] + )); } #[test] diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index d1d85f076..ddfc4f1dc 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -4687,6 +4687,19 @@ fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { ); } +#[test] +fn a_kicked_member_gets_the_empty_uninvite_packet() { + use lyracore_shared::group::event_kind; + let (realm, _world, _instances, _calls) = party_topology(); + let row = group_event(event_kind::UNINVITED, 0, ""); + + let packets = + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + + assert_eq!(packets.len(), 1); + assert_eq!(wire(&packets[0]), (0x0077, Vec::new())); +} + #[test] fn world_entry_asks_for_a_partys_target_icons_after_the_list() { let (realm, world, instances, _calls) = party_topology(); diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs index 568ca1b1f..dbc97b881 100644 --- a/gateway/src/world/routing.rs +++ b/gateway/src/world/routing.rs @@ -93,6 +93,7 @@ protocol_families! { CMSG_QUESTGIVER_QUERY_QUEST, CMSG_QUEST_QUERY, CMSG_QUESTGIVER_ACCEPT_QUEST, + CMSG_QUESTGIVER_CANCEL, CMSG_QUESTGIVER_COMPLETE_QUEST, CMSG_QUESTGIVER_REQUEST_REWARD, CMSG_QUESTGIVER_CHOOSE_REWARD, diff --git a/module/src/group.rs b/module/src/group.rs index 72310a2a3..24494b181 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1551,6 +1551,16 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure let group_id = m.group_id; crate::loot::tag::revoke_group_member(ctx, group_id, character_guid); ctx.db.game_group_member().id().delete(m.id); + let remaining = members_of(ctx, group_id); + if departure == Departure::Kicked && remaining.len() >= 2 { + push_event( + ctx, + character_guid, + group_event_kind::UNINVITED, + 0, + String::new(), + ); + } push_event( ctx, character_guid, @@ -1559,7 +1569,6 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure String::new(), ); - let remaining = members_of(ctx, group_id); let group = ctx.db.game_group().group_id().find(group_id); let rematch = match leader_after_removal( group.as_ref().map_or(GroupKind::Party, group_kind_of), diff --git a/module/src/quest.rs b/module/src/quest.rs index 42dffef0d..2781d73a6 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -519,7 +519,7 @@ pub(crate) fn accept_gates( player: &crate::WorldEntity, tmpl: &QuestTemplate, ) -> Result, String> { - use lyracore_shared::quest::{acceptance, AcceptanceRefusal, AcceptanceRequirements, Attempt}; + use lyracore_shared::quest::{acceptance, AcceptanceRequirements, Attempt}; let existing = character_quest_row(ctx, player.guid, tmpl.entry); acceptance( AcceptanceRequirements { @@ -537,13 +537,7 @@ pub(crate) fn accept_gates( failed: row.failed, }), ) - .map_err(|refusal| match refusal { - AcceptanceRefusal::Level => format!("requires level {}", tmpl.min_level), - AcceptanceRefusal::Race => "quest not available to your race".to_string(), - AcceptanceRefusal::Class => "quest not available to your class".to_string(), - AcceptanceRefusal::Prerequisite => "must complete the prerequisite quest first".to_string(), - AcceptanceRefusal::AlreadyTaken => "already on or completed that quest".to_string(), - })?; + .map_err(|refusal| refusal.text(tmpl.min_level))?; Ok(existing) } @@ -583,6 +577,13 @@ pub(crate) fn request_accept_quest( .find(quest_entry) .ok_or_else(|| format!("no such quest {quest_entry}"))?; let existing = accept_gates(ctx, &player, &tmpl)?; + if existing.as_ref().is_none_or(|row| row.rewarded) + && quest_slots_used(ctx, player_guid) >= MAX_QUEST_LOG_SIZE + { + return Err(lyracore_shared::quest::QUEST_LOG_FULL_TEXT + .to_string() + .into()); + } let deadline_micros = if tmpl.limit_time > 0 { ctx.timestamp.to_micros_since_unix_epoch() + (tmpl.limit_time as i64) * 1_000_000 } else { @@ -2693,7 +2694,7 @@ fn evaluate_share( let existing = character_quest_row(ctx, member_guid, tmpl.entry); let member_active = existing.as_ref().is_some_and(|q| !q.rewarded && !q.failed); let member_finished = existing.as_ref().is_some_and(|q| q.rewarded) && !tmpl.repeatable; - let log_full = active_quest_count(ctx, member_guid) >= MAX_QUEST_LOG_SIZE; + let log_full = quest_slots_used(ctx, member_guid) >= MAX_QUEST_LOG_SIZE; let gates_ok = member_entity .as_ref() .is_some_and(|e| accept_gates(ctx, e, tmpl).is_ok()); @@ -2708,12 +2709,13 @@ fn evaluate_share( ) } -fn active_quest_count(ctx: &ReducerContext, character_guid: u64) -> usize { +/// Every un-rewarded row, failed ones included, because the client log shows them. +fn quest_slots_used(ctx: &ReducerContext, character_guid: u64) -> usize { ctx.db .game_character_quest() .by_character() .filter(&character_guid) - .filter(|q| !q.rewarded && !q.failed) + .filter(|q| !q.rewarded) .count() } diff --git a/module/tests/group_uninvite.rs b/module/tests/group_uninvite.rs new file mode 100644 index 000000000..3085e7a91 --- /dev/null +++ b/module/tests/group_uninvite.rs @@ -0,0 +1,124 @@ +mod support; + +use serde_json::json; +use support::Standalone; + +const HUMAN: u8 = 1; +const WARRIOR: u8 = 1; +const STONE_AT: (f32, f32, f32) = (-8949.95, -132.493, 83.5312); + +const DESTROYED: u8 = 3; +const UNINVITED: u8 = 31; + +fn start(name: &str) -> Standalone { + let mut realm = Standalone::start(name); + realm.publish_module(); + realm.assert_call("claim_operator", &[]); + realm.assert_call("install_guid_range", &["0"]); + realm.assert_sql("DELETE FROM game_event_reaper_schedule"); + realm +} + +fn stage(realm: &Standalone, guid: u64) { + let (x, y, z) = STONE_AT; + realm.assert_call( + "debug_stage_meeting_stone_character", + &[ + &guid.to_string(), + &guid.to_string(), + &HUMAN.to_string(), + &WARRIOR.to_string(), + "18", + "0", + &x.to_string(), + &y.to_string(), + &z.to_string(), + ], + ); +} + +fn stage_group(realm: &Standalone, leader: u64, members: &[u64]) { + realm.assert_call( + "debug_stage_meeting_stone_group", + &[&leader.to_string(), &json!(members).to_string(), "false"], + ); +} + +fn session(guid: u64) -> String { + json!({ + "guid": guid, + "ownership": { "some": { "account_id": guid, "generation": 1, "request_nonce": guid } }, + }) + .to_string() +} + +fn heard(realm: &Standalone, guid: u64) -> Vec { + let mut rows: Vec<(u64, u8)> = realm + .query_rows(&format!( + "SELECT id, kind FROM game_group_event WHERE recipient_guid = {guid} \ + AND (kind = {DESTROYED} OR kind = {UNINVITED})" + )) + .into_iter() + .map(|row| (row["id"].parse().unwrap(), row["kind"].parse().unwrap())) + .collect(); + rows.sort_unstable(); + rows.into_iter().map(|(_, kind)| kind).collect() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_member_kicked_from_a_surviving_group_hears_uninvite_before_destroyed() { + let realm = start("group-uninvite-survives"); + const LEADER: u64 = 509_6100; + const KICKED: u64 = 509_6101; + const STAYS: u64 = 509_6102; + for guid in [LEADER, KICKED, STAYS] { + stage(&realm, guid); + } + stage_group(&realm, LEADER, &[KICKED, STAYS]); + + realm.assert_call( + "gw_group_uninvite", + &[&session(LEADER), &KICKED.to_string()], + ); + + assert_eq!(heard(&realm, KICKED), [UNINVITED, DESTROYED]); + assert!(heard(&realm, STAYS).is_empty()); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_kick_that_disbands_the_group_sends_only_destroyed() { + let realm = start("group-uninvite-disbands"); + const LEADER: u64 = 509_6110; + const KICKED: u64 = 509_6111; + for guid in [LEADER, KICKED] { + stage(&realm, guid); + } + stage_group(&realm, LEADER, &[KICKED]); + + realm.assert_call( + "gw_group_uninvite", + &[&session(LEADER), &KICKED.to_string()], + ); + + assert_eq!(heard(&realm, KICKED), [DESTROYED]); + assert_eq!(heard(&realm, LEADER), [DESTROYED]); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_member_who_leaves_hears_no_uninvite() { + let realm = start("group-uninvite-leave"); + const LEADER: u64 = 509_6120; + const LEAVER: u64 = 509_6121; + const STAYS: u64 = 509_6122; + for guid in [LEADER, LEAVER, STAYS] { + stage(&realm, guid); + } + stage_group(&realm, LEADER, &[LEAVER, STAYS]); + + realm.assert_call("gw_group_leave", &[&session(LEAVER)]); + + assert_eq!(heard(&realm, LEAVER), [DESTROYED]); +} diff --git a/module/tests/meeting_stone_matching.rs b/module/tests/meeting_stone_matching.rs index 57def6e01..a87fe47a8 100644 --- a/module/tests/meeting_stone_matching.rs +++ b/module/tests/meeting_stone_matching.rs @@ -192,7 +192,7 @@ fn events(realm: &Standalone) -> Vec<(u64, u64, Event)> { let mut rows: Vec<_> = realm .query_rows(&format!( "SELECT id, recipient_guid, kind, other_guid, payload FROM game_group_event \ - WHERE kind = {LIST} OR kind >= {QUEUE}" + WHERE kind = {LIST} OR (kind >= {QUEUE} AND kind <= {COMPLETE})" )) .into_iter() .map(|row| { diff --git a/module/tests/quest_log_full.rs b/module/tests/quest_log_full.rs new file mode 100644 index 000000000..af88ed427 --- /dev/null +++ b/module/tests/quest_log_full.rs @@ -0,0 +1,135 @@ +mod support; + +use support::{actor, Standalone}; + +const CHARACTER: &str = "1"; +const FIRST_QUEST: u32 = 5_096_500; +const LOG_SIZE: u32 = 20; +const WOLF: u32 = 51_000; +const REPEATABLE: u32 = FIRST_QUEST + LOG_SIZE + 1; + +fn quest_rows(shard: &Standalone) -> usize { + shard + .query_rows(&format!( + "SELECT quest_entry FROM game_character_quest WHERE character_guid = {CHARACTER}" + )) + .len() +} + +fn accept(shard: &Standalone, giver: &str, quest: u32) -> std::process::Output { + shard.call( + "debug_accept_quest", + &[CHARACTER, giver, &quest.to_string()], + ) +} + +fn setup(name: &str) -> (Standalone, String) { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &[CHARACTER]); + shard.assert_sql(&format!( + "DELETE FROM game_world_entity WHERE entry = {WOLF}" + )); + shard.assert_call("debug_spawn_at_feet", &[CHARACTER, &WOLF.to_string(), "3"]); + let giver = shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE entry = {WOLF}" + )) + .remove(0)["guid"] + .clone(); + for offset in 0..=LOG_SIZE + 1 { + let entry = FIRST_QUEST + offset; + let repeatable = entry == REPEATABLE; + shard.assert_sql(&format!( + "INSERT INTO game_quest_template \ + (entry,min_level,quest_level,title,reward_money,reward_xp,prev_quest_id,\ + required_races,required_classes,zone_or_sort,rew_rep_faction_1,rew_rep_value_1,\ + rew_rep_faction_2,rew_rep_value_2,src_item,src_item_count,repeatable,next_quest_id,\ + limit_time,reward_money_max_level,quest_type) \ + VALUES ({entry},0,2,'Full log quest',0,0,0,0,0,12,0,0,0,0,0,0,{repeatable},0,0,0,0)" + )); + shard.assert_sql(&format!( + "INSERT INTO game_creature_quest (id,creature_entry,quest_entry,role) \ + VALUES ({},{WOLF},{entry},0)", + u64::from(entry) + )); + } + (shard, giver) +} + +fn grant(shard: &Standalone, quest: u32) { + shard.assert_call("debug_grant_quest", &[CHARACTER, &quest.to_string()]); +} + +fn assert_full_log_refusal(output: &std::process::Output) { + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("quest log is full"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_full_quest_log_refuses_a_new_accept_and_adds_no_row() { + let (shard, giver) = setup("quest-log-full"); + for offset in 0..LOG_SIZE { + grant(&shard, FIRST_QUEST + offset); + } + assert_eq!(quest_rows(&shard), LOG_SIZE as usize); + + let twenty_first = FIRST_QUEST + LOG_SIZE; + assert_full_log_refusal(&accept(&shard, &giver, twenty_first)); + assert_eq!( + quest_rows(&shard), + LOG_SIZE as usize, + "a Refusal adds no row" + ); + + shard.assert_call( + "gw_abandon_quest", + &[&actor(CHARACTER), &FIRST_QUEST.to_string()], + ); + assert!(accept(&shard, &giver, twenty_first).status.success()); + assert_eq!(quest_rows(&shard), LOG_SIZE as usize); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_rewarded_repeatable_quest_needs_a_free_slot_to_return() { + let (shard, giver) = setup("quest-log-repeatable"); + grant(&shard, REPEATABLE); + shard.assert_sql(&format!( + "UPDATE game_character_quest SET rewarded = true WHERE quest_entry = {REPEATABLE}" + )); + for offset in 0..LOG_SIZE - 1 { + grant(&shard, FIRST_QUEST + offset); + } + assert!(accept(&shard, &giver, REPEATABLE).status.success()); + + shard.assert_sql(&format!( + "UPDATE game_character_quest SET rewarded = true WHERE quest_entry = {REPEATABLE}" + )); + grant(&shard, FIRST_QUEST + LOG_SIZE - 1); + assert_full_log_refusal(&accept(&shard, &giver, REPEATABLE)); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_failed_quest_keeps_its_slot_until_it_is_taken_again() { + let (shard, giver) = setup("quest-log-failed"); + for offset in 0..LOG_SIZE { + grant(&shard, FIRST_QUEST + offset); + } + shard.assert_call("debug_expire_quest", &[CHARACTER, &FIRST_QUEST.to_string()]); + + assert_full_log_refusal(&accept(&shard, &giver, FIRST_QUEST + LOG_SIZE)); + assert!( + accept(&shard, &giver, FIRST_QUEST).status.success(), + "a failed quest resets in its own slot" + ); +}