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: 2 additions & 0 deletions crates/lyracore-shared/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions crates/lyracore-shared/src/quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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
Expand All @@ -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 = [
Expand Down
60 changes: 60 additions & 0 deletions gateway/src/codec/quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<u8> {
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;
45 changes: 44 additions & 1 deletion gateway/src/codec/quest/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ pub(crate) fn quest_update_feedback(
)))
})
.collect::<Vec<_>>();
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(
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<Outbound>) -> Vec<Outbound> {
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,
Expand Down
44 changes: 44 additions & 0 deletions gateway/src/codec/quest/subscriptions/audience_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
1 change: 1 addition & 0 deletions gateway/src/codec/social/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/stdb/relays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading