diff --git a/CHANGELOG.md b/CHANGELOG.md index af05f9a07..95e7dda88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and is not reconstructed here. ### Changed +- Package API version 4 adds `actor::EffectKind`, the shared spell effect taxonomy. Existing + Actor byte constants keep their values. Package authors can use `EffectKind::try_from` + when reading effect rows; unknown bytes remain possible in authored content. The five API + roots from version 3 stay the same. + - Package API version 3 has five roots: `actor`, `hooks`, `package`, `tables`, and `script_binding`. Actor operations and observations replace direct dependencies on `chat`, `combat`, `creatures`, `faction`, `gameobject`, `group`, `helpers`, `items`, `loot`, `nav`, `quest`, diff --git a/CONTEXT.md b/CONTEXT.md index a1ba944a3..ec76caa4d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,7 +15,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **D** [Datascript](#packages) · [Delivery Delay](#mail) · [Dismount](#mounts) · [Distraction](#creature-ai) · [Dormant Character](#accounts-characters-and-sessions) · [Durable Read](#gateway-and-module) · [Durable Request](#gateway-and-module) -**E** [Encounter Binding](#realm-topology) · [Encounter Signal](#realm-topology) · [Engagement](#creature-ai) · [Entity Handle](#runtime-scripts) · [Escrow](#sharding-and-transfer) · [Event Binding](#packages) · [EventAI Source Profile](#realm-topology) +**E** [Effect Kind](#gateway-and-module) · [Encounter Binding](#realm-topology) · [Encounter Signal](#realm-topology) · [Engagement](#creature-ai) · [Entity Handle](#runtime-scripts) · [Escrow](#sharding-and-transfer) · [Event Binding](#packages) · [EventAI Source Profile](#realm-topology) **F** [Fake](#working-method) · [Fee Hold](#guilds) · [Fixed Rout](#creature-ai) · [Fixture-Reserved Identifier](#packages) · [Flat Cast](#creature-ai) · [Forced Death](#creature-ai) · [Foreground Action](#sharding-and-transfer) · [Fuel Budget](#runtime-scripts) @@ -162,6 +162,11 @@ The Gateway's subscribed connection per shard, authenticated with the Owner Toke **Owner Token**: The credential that bypasses row-level security. +**Effect Kind**: +A known durable spell effect code. The shared `EffectKind` enum defines its byte value and whether +it applies an aura. Imported and authored table rows keep their byte encoding; unknown codes +remain possible and consumers handle them explicitly. + **Relay**: Gateway code that turns a table change into a client message. _Avoid_: forwarder, pusher diff --git a/crates/lyracore-shared/src/aura.rs b/crates/lyracore-shared/src/aura.rs new file mode 100644 index 000000000..fd6aae463 --- /dev/null +++ b/crates/lyracore-shared/src/aura.rs @@ -0,0 +1,126 @@ +//! Pure folds over active aura values. + +use crate::spell::taxonomy::*; + +#[derive(Clone, Copy, Debug)] +pub struct AuraAmount { + pub kind: u8, + pub parameter: i32, + pub amount: i32, + pub stacks: u8, +} + +/// Legacy zero-stack rows contribute once. Arithmetic follows the Module's signed aura fold. +pub fn stacked_amount(amount: i32, stacks: u8) -> i32 { + amount * i32::from(stacks.max(1)) +} + +pub fn moves_sheet(kind: u8, parameter: i32) -> bool { + (kind == A_MOD_STAT + && [STAT_STR, STAT_AGI, STAT_STA, STAT_INT, STAT_SPI, STAT_ALL] + .into_iter() + .any(|stat| parameter == i32::from(stat))) + || (kind == A_MOD_COMBAT + && [COMBAT_ATTACK_POWER, COMBAT_CRIT] + .into_iter() + .any(|field| parameter == i32::from(field))) +} + +pub fn speed_bonus(auras: impl IntoIterator, speed_kind: u8) -> i32 { + auras + .into_iter() + .filter(|a| a.kind == A_MOD_SPEED && a.parameter == i32::from(speed_kind)) + .map(|a| stacked_amount(a.amount, a.stacks)) + .sum() +} + +/// Movement retains at least ten percent of its base speed. +pub fn move_speed_with_pct(base: f32, pct: i32) -> f32 { + base * (100 + pct).max(10) as f32 / 100.0 +} + +/// Running combines movement auras with mounted speed only while a mount aura is active. +pub fn run_speed(base: f32, auras: impl IntoIterator) -> f32 { + let (mut movement, mut mount, mut mounted) = (0, 0, false); + for aura in auras { + mounted |= aura.kind == A_MOUNTED; + if aura.kind == A_MOD_SPEED { + if aura.parameter == i32::from(SPEED_MOVE) { + movement += stacked_amount(aura.amount, aura.stacks); + } else if aura.parameter == i32::from(SPEED_MOUNTED) { + mount += stacked_amount(aura.amount, aura.stacks); + } + } + } + move_speed_with_pct(base, mounted_move_pct(movement, mount, mounted)) +} + +pub fn mounted_move_pct(movement: i32, mount: i32, mounted: bool) -> i32 { + movement + if mounted { mount } else { 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sheet_changes_include_crit_and_only_valid_stats() { + assert!(moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_CRIT))); + assert!(moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_ATTACK_POWER))); + assert!(!moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_HIT))); + assert!(moves_sheet(A_MOD_STAT, i32::from(STAT_ALL))); + assert!(moves_sheet(A_MOD_STAT, i32::from(STAT_SPI))); + assert!(!moves_sheet(A_MOD_STAT, 5)); + assert!(!moves_sheet(A_MOD_STAT, -1)); + } + + #[test] + fn stacked_snare_reaches_the_movement_floor() { + let auras = [ + AuraAmount { + kind: A_MOD_SPEED, + parameter: i32::from(SPEED_MOVE), + amount: -30, + stacks: 4, + }, + AuraAmount { + kind: A_MOD_SPEED, + parameter: i32::from(SPEED_MOVE), + amount: 10, + stacks: 0, + }, + AuraAmount { + kind: A_MOD_SPEED, + parameter: i32::from(SPEED_SWING), + amount: 50, + stacks: 1, + }, + ]; + assert_eq!(speed_bonus(auras, SPEED_MOVE), -110); + assert!((move_speed_with_pct(7.0, speed_bonus(auras, SPEED_MOVE)) - 0.7).abs() < 0.0001); + } + #[test] + fn running_applies_mount_speed_only_while_mounted() { + let movement = AuraAmount { + kind: A_MOD_SPEED, + parameter: i32::from(SPEED_MOVE), + amount: -30, + stacks: 1, + }; + let mount = AuraAmount { + kind: A_MOD_SPEED, + parameter: i32::from(SPEED_MOUNTED), + amount: 60, + stacks: 1, + }; + let mounted = AuraAmount { + kind: A_MOUNTED, + parameter: 1147, + amount: 0, + stacks: 1, + }; + assert_eq!(run_speed(7.0, [movement, mount, mounted]), 9.1); + assert_eq!(run_speed(7.0, [mount, mounted]), 11.2); + assert_eq!(run_speed(7.0, [movement, mount]), 4.9); + } +} diff --git a/crates/lyracore-shared/src/faction.rs b/crates/lyracore-shared/src/faction.rs index 05f6c5399..498abaf03 100644 --- a/crates/lyracore-shared/src/faction.rs +++ b/crates/lyracore-shared/src/faction.rs @@ -44,3 +44,82 @@ mod tests { assert!(!same_team(1, 2), "Human and Orc are not"); } } + +pub fn reputation_rank(standing: i32) -> u8 { + match standing { + 42000.. => 7, + 21000.. => 6, + 9000.. => 5, + 3000.. => 4, + 0.. => 3, + -3000.. => 2, + -6000.. => 1, + _ => 0, + } +} + +/// Explicit relations take precedence over group masks. Faction zero has no explicit relation. +pub fn relation_holds( + asserting: &[u32; 4], + vetoing: &[u32; 4], + mask: u32, + other_faction: u32, + other_group: u32, +) -> bool { + if other_faction != 0 { + if asserting.contains(&other_faction) { + return true; + } + if vetoing.contains(&other_faction) { + return false; + } + } + mask & other_group != 0 +} + +/// A standing row for a faction with a reputation bar overrides template hostility. +pub fn refuses_interaction(reputation: Option<(i32, bool)>, template_hostile: bool) -> bool { + reputation.map_or(template_hostile, |(standing, at_war)| { + at_war || reputation_rank(standing) <= 2 + }) +} + +#[cfg(test)] +mod rule_tests { + use super::*; + + #[test] + fn reputation_ranks_change_at_each_vanilla_threshold() { + for (standing, rank) in [ + (-6000, 1), + (-3000, 2), + (0, 3), + (3000, 4), + (9000, 5), + (21000, 6), + (42000, 7), + ] { + assert_eq!(reputation_rank(standing), rank); + assert_eq!(reputation_rank(standing - 1), rank - 1); + } + assert_eq!(reputation_rank(i32::MIN), 0); + assert_eq!(reputation_rank(i32::MAX), 7); + } + + #[test] + fn explicit_hostility_beats_friendship_and_friendship_beats_the_mask() { + assert!(relation_holds(&[7, 0, 0, 0], &[7, 0, 0, 0], 0, 7, 1)); + assert!(!relation_holds(&[0; 4], &[7, 0, 0, 0], 1, 7, 1)); + assert!(relation_holds(&[0; 4], &[0; 4], 1, 0, 1)); + assert!(!relation_holds(&[0; 4], &[0; 4], 0, 0, 1)); + } + + #[test] + fn reputation_and_at_war_override_template_hostility() { + assert!(refuses_interaction(Some((42000, true)), false)); + assert!(refuses_interaction(Some((-1, false)), false)); + assert!(!refuses_interaction(Some((0, false)), true)); + assert!(refuses_interaction(None, true)); + assert!(!refuses_interaction(None, false)); + } +} diff --git a/crates/lyracore-shared/src/item.rs b/crates/lyracore-shared/src/item.rs index cda510291..d83768e84 100644 --- a/crates/lyracore-shared/src/item.rs +++ b/crates/lyracore-shared/src/item.rs @@ -1,5 +1,36 @@ //! Item rules shared by the module and gateway. +pub const fn is_carried_slot(slot: u8) -> bool { + slot <= 38 || (slot >= 120 && slot <= 191) +} + +pub fn carried_quantity(entry: u32, items: impl IntoIterator) -> u32 { + items + .into_iter() + .filter(|&(slot, item_entry, _)| is_carried_slot(slot) && item_entry == entry) + .map(|(_, _, count)| count) + .sum() +} + +/// Any projectile class is suitable; launcher subclass pairing is not enforced by the Module. +pub fn loaded_ammo( + items: impl IntoIterator, + fields: impl Fn(&T) -> (u8, u32, Option), +) -> Option { + items + .into_iter() + .filter_map(|item| { + let (slot, count, class) = fields(&item); + (is_carried_slot(slot) && count > 0 && class == Some(6)).then_some((slot, item)) + }) + .min_by_key(|(slot, _)| *slot) + .map(|(_, item)| item) +} + +pub const fn grants_equipped_stats(slot: u8, max_durability: u32, durability: u32) -> bool { + slot <= 18 && !(max_durability > 0 && durability == 0) +} + /// Why the Module refused an item Durable Request. The tag is the whole reducer error text, so /// neither tier matches on human prose; the detail stays in the Module log. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -248,6 +279,39 @@ mod tests { use armor_subclass::*; + #[test] + fn carried_quantity_excludes_bank_and_unknown_slots() { + assert_eq!( + carried_quantity( + 7, + [ + (0, 7, 1), + (38, 7, 2), + (39, 7, 40), + (119, 7, 40), + (120, 7, 3), + (191, 7, 4), + (192, 7, 40), + (23, 8, 50) + ] + ), + 10 + ); + } + + #[test] + fn loaded_ammo_uses_the_lowest_carried_nonempty_projectile() { + let items = [ + (39, 20, Some(6)), + (120, 5, Some(6)), + (191, 7, Some(6)), + (23, 0, Some(6)), + (24, 8, Some(2)), + (25, 1, None), + ]; + assert_eq!(loaded_ammo(items, |item| *item), Some((120, 5, Some(6)))); + } + /// The nine playable classes (6 and 10 are unused in 1.12). const PLAYABLE: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11]; diff --git a/crates/lyracore-shared/src/item_property.rs b/crates/lyracore-shared/src/item_property.rs index 25ccf5f33..0fd4447a7 100644 --- a/crates/lyracore-shared/src/item_property.rs +++ b/crates/lyracore-shared/src/item_property.rs @@ -27,6 +27,41 @@ pub const DODGE: u8 = 23; pub const PARRY: u8 = 24; pub const BLOCK: u8 = 25; +pub fn enchant_stat( + kind: u8, + school_mask: u32, + effects: impl IntoIterator, +) -> i32 { + effects + .into_iter() + .filter(|&(effect_kind, _, mask)| { + effect_kind != UNKNOWN && effect_kind == kind && (mask == 0 || mask & school_mask != 0) + }) + .fold(0i32, |sum, (_, amount, _)| sum.saturating_add(amount)) +} + +pub fn property_stat(enchantments: [u32; 3], mut enchant_stat: impl FnMut(u32) -> i32) -> i32 { + enchantments + .into_iter() + .map(&mut enchant_stat) + .fold(0i32, i32::saturating_add) +} + +pub fn item_stat(template: i32, enchantment: i32, property: i32) -> i32 { + template + .saturating_add(enchantment) + .saturating_add(property) +} + +pub fn equipped_stat(items: impl IntoIterator) -> i32 { + items + .into_iter() + .filter(|&(slot, max_durability, durability, _)| { + crate::item::grants_equipped_stats(slot, max_durability, durability) + }) + .fold(0i32, |sum, (_, _, _, amount)| sum.saturating_add(amount)) +} + /// Authored compatibility entries as `(stored, client, kind, amount)`. Stored items and synthetic /// enchanting spells use the stored ID, which the build 5875 client catalogue lacks. The Gateway /// sends the client ID instead: the Enchanting result with the same Stat Kind and amount. Random @@ -47,6 +82,47 @@ pub fn client_enchantment_id(stored: u32) -> u32 { mod tests { use super::*; + #[test] + fn enchantment_effects_require_the_stat_kind_and_school() { + let effects = [ + (SPELL_POWER, 7, 0), + (SPELL_POWER, 11, 4), + (SPELL_POWER, 13, 16), + (ARMOR, 100, 0), + (UNKNOWN, 100, 0), + ]; + assert_eq!(enchant_stat(SPELL_POWER, 4, effects), 18); + assert_eq!(enchant_stat(UNKNOWN, u32::MAX, effects), 0); + } + + #[test] + fn contributions_saturate_in_template_enchantment_property_order() { + let enchant = enchant_stat( + ARMOR, + u32::MAX, + [(ARMOR, i32::MAX, 0), (ARMOR, 10, 0), (ARMOR, -5, 0)], + ); + assert_eq!(enchant, i32::MAX - 5); + let property = property_stat([1, 2, 3], |id| match id { + 1 => i32::MAX, + 2 => 10, + 3 => -30, + _ => 0, + }); + assert_eq!(property, i32::MAX - 30); + assert_eq!(item_stat(i32::MAX, 10, -50), i32::MAX - 50); + assert_eq!( + equipped_stat([ + (4, 100, 1, i32::MAX), + (5, 100, 0, -100), + (23, 0, 0, -100), + (18, 0, 0, 10), + (14, 0, 0, -5) + ]), + i32::MAX - 5 + ); + } + #[test] fn compatibility_entries_map_to_client_enchantments_with_the_same_text() { assert_eq!(client_enchantment_id(7745), 823); diff --git a/crates/lyracore-shared/src/lib.rs b/crates/lyracore-shared/src/lib.rs index 7e9e9de00..a99418dbb 100644 --- a/crates/lyracore-shared/src/lib.rs +++ b/crates/lyracore-shared/src/lib.rs @@ -8,6 +8,7 @@ //! lives in the module. See `docs/` for the design these constants implement. pub mod auction; +pub mod aura; pub mod bank; pub mod calendar; pub mod channel; @@ -31,6 +32,7 @@ pub mod packing; pub mod pet; pub mod quest; pub mod region; +pub mod resistance; pub mod social; pub mod spatial; pub mod terrain; diff --git a/crates/lyracore-shared/src/loot.rs b/crates/lyracore-shared/src/loot.rs index 38eeabd96..109641e12 100644 --- a/crates/lyracore-shared/src/loot.rs +++ b/crates/lyracore-shared/src/loot.rs @@ -115,3 +115,106 @@ mod tests { } } } + +/// A reservation is the grant; only an unreserved quest row requires current need. +pub fn quest_take_allowed(reserved_for: u64, taker_guid: u64, taker_needs_item: bool) -> bool { + reserved_for == taker_guid || (reserved_for == 0 && taker_needs_item) +} + +pub fn group_loot_take_allowed( + withheld: bool, + reserved_for: u64, + master_only: bool, + designated_looter_guid: u64, + taker_guid: u64, +) -> bool { + if withheld { + return false; + } + if reserved_for != 0 { + return reserved_for == taker_guid; + } + !master_only && (designated_looter_guid == 0 || designated_looter_guid == taker_guid) +} + +#[derive(Clone, Copy)] +pub struct QuestItemObjective { + pub quest_entry: u32, + pub quest_type: u32, + pub kind: u8, + pub target_entry: u32, + pub required_count: u32, +} + +/// Decide item need from active quests, objective kinds, quantities and Raid membership. +pub fn needs_quest_item( + in_raid: bool, + active_quests: &[u32], + objectives: impl IntoIterator, + item: u32, + held: u32, +) -> bool { + objectives.into_iter().any(|objective| { + objective.kind == crate::quest::objective_kind::COLLECT_ITEM + && active_quests.contains(&objective.quest_entry) + && objective.target_entry == item + && held < objective.required_count.max(1) + && crate::quest::quest_progresses_for_raid(in_raid, objective.quest_type) + }) +} + +#[cfg(test)] +mod rule_tests { + use super::*; + + #[test] + fn withheld_loot_stays_owned_by_the_roll_even_with_a_reservation() { + assert!(!group_loot_take_allowed(true, 7, false, 0, 7)); + assert!(group_loot_take_allowed(false, 7, true, 8, 7)); + assert!(!group_loot_take_allowed(false, 7, false, 0, 8)); + } + + #[test] + fn a_quest_reservation_survives_the_characters_need() { + assert!(quest_take_allowed(7, 7, false)); + assert!(!quest_take_allowed(7, 8, true)); + assert!(quest_take_allowed(0, 8, true)); + assert!(!quest_take_allowed(0, 8, false)); + } + + #[test] + fn collect_need_respects_raid_type_quantity_and_the_minimum_requirement() { + let collect = |quest_type, target_entry, required_count| QuestItemObjective { + quest_entry: 100, + quest_type, + kind: 1, + target_entry, + required_count, + }; + assert!(needs_quest_item(false, &[100], [collect(0, 55, 10)], 55, 9)); + assert!(!needs_quest_item( + false, + &[100], + [collect(0, 55, 10)], + 55, + 10 + )); + assert!(!needs_quest_item(true, &[100], [collect(0, 55, 10)], 55, 0)); + assert!(needs_quest_item(true, &[100], [collect(62, 55, 10)], 55, 0)); + assert!(needs_quest_item(false, &[100], [collect(0, 55, 0)], 55, 0)); + assert!(!needs_quest_item(false, &[100], [collect(0, 55, 0)], 55, 1)); + assert!(!needs_quest_item( + false, + &[100], + [collect(0, 56, 10)], + 55, + 0 + )); + assert!(!needs_quest_item(false, &[], [collect(0, 55, 10)], 55, 0)); + let kill = QuestItemObjective { + kind: 0, + ..collect(0, 55, 10) + }; + assert!(!needs_quest_item(false, &[100], [kill], 55, 0)); + } +} diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index aa1d37d13..e914ab593 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -321,3 +321,243 @@ mod tests { ); } } + +/// Durable objective-kind bytes. +pub mod objective_kind { + pub const KILL_CREATURE: u8 = 0; + pub const COLLECT_ITEM: u8 = 1; + pub const USE_GAMEOBJECT: u8 = 2; + pub const EXPLORE_AREATRIGGER: u8 = 3; +} + +/// Facts for one objective. Inventory quantities are supplied by the owning tier. +#[derive(Clone, Copy)] +pub struct Objective { + pub kind: u8, + pub index: u8, + pub target_entry: u32, + pub required_count: u32, +} + +/// All objectives and any event requirement must be met. This does not check quest state. +pub fn objectives_complete( + objectives: impl IntoIterator, + counts: &[u32], + mut carried_item_count: impl FnMut(u32) -> u32, + requires_event_credit: bool, + has_event_credit: bool, +) -> bool { + objectives.into_iter().all(|objective| { + let have = if objective.kind == objective_kind::COLLECT_ITEM { + carried_item_count(objective.target_entry) + } else { + counts.get(objective.index as usize).copied().unwrap_or(0) + }; + have >= objective.required_count + }) && (!requires_event_credit || has_event_credit) +} + +#[derive(Clone, Copy)] +pub struct AcceptanceRequirements { + pub min_level: u32, + pub required_races: u32, + pub required_classes: u32, + pub repeatable: bool, +} + +#[derive(Clone, Copy)] +pub struct Attempt { + pub rewarded: bool, + pub failed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcceptanceRefusal { + Level, + Race, + Class, + Prerequisite, + AlreadyTaken, +} + +/// Apply acceptance Gates in the Module's order. A failed attempt may always be retried. +pub fn acceptance( + requirements: AcceptanceRequirements, + level: u32, + race: u8, + class: u8, + prerequisite_rewarded: bool, + existing: Option, +) -> Result<(), AcceptanceRefusal> { + if level < requirements.min_level { + return Err(AcceptanceRefusal::Level); + } + if !race_allowed(requirements.required_races, race) { + return Err(AcceptanceRefusal::Race); + } + if !class_allowed(requirements.required_classes, class) { + return Err(AcceptanceRefusal::Class); + } + if !prerequisite_rewarded { + return Err(AcceptanceRefusal::Prerequisite); + } + if existing + .is_some_and(|attempt| !(attempt.failed || (requirements.repeatable && attempt.rewarded))) + { + return Err(AcceptanceRefusal::AlreadyTaken); + } + Ok(()) +} + +/// Resolve the unscaled reward. The Module applies the realm XP rate after this selection. +pub fn base_reward_xp(explicit_xp: u32, reward_money_max_level: u32, quest_level: u32) -> u32 { + if explicit_xp > 0 { + explicit_xp + } else if reward_money_max_level > 0 { + quest_xp(reward_money_max_level) + } else { + xp_reward(quest_level) + } +} + +#[cfg(test)] +mod rule_tests { + use super::*; + + #[test] + fn completion_reads_each_objective_kind_from_its_authority() { + let objectives = [ + Objective { + kind: 0, + index: 0, + target_entry: 80, + required_count: 3, + }, + Objective { + kind: 1, + index: 1, + target_entry: 90, + required_count: 2, + }, + Objective { + kind: 3, + index: 2, + target_entry: 100, + required_count: 1, + }, + ]; + assert!(objectives_complete( + objectives, + &[3, 0, 1], + |_| 2, + false, + false + )); + assert!(!objectives_complete( + objectives, + &[3, 99, 1], + |_| 1, + false, + false + )); + assert!(!objectives_complete( + objectives, + &[3, 99], + |_| 2, + false, + false + )); + } + + #[test] + fn event_credit_is_required_even_when_no_ordinary_objectives_exist() { + assert!(!objectives_complete([], &[], |_| 0, true, false)); + assert!(objectives_complete([], &[], |_| 0, true, true)); + assert!(objectives_complete([], &[], |_| 0, false, false)); + let kill = Objective { + kind: 0, + index: 0, + target_entry: 80, + required_count: 3, + }; + assert!(!objectives_complete([kill], &[2], |_| 0, true, true)); + } + + fn requirements(repeatable: bool) -> AcceptanceRequirements { + AcceptanceRequirements { + min_level: 10, + required_races: 1, + required_classes: 2, + repeatable, + } + } + + #[test] + fn failed_attempts_can_be_retried_without_making_completed_quests_repeatable() { + let failed = Some(Attempt { + failed: true, + rewarded: false, + }); + let rewarded = Some(Attempt { + failed: false, + rewarded: true, + }); + assert_eq!( + acceptance(requirements(false), 10, 1, 2, true, failed), + Ok(()) + ); + assert_eq!( + acceptance(requirements(false), 10, 1, 2, true, rewarded), + Err(AcceptanceRefusal::AlreadyTaken) + ); + assert_eq!( + acceptance(requirements(true), 10, 1, 2, true, rewarded), + Ok(()) + ); + assert_eq!( + acceptance( + requirements(true), + 10, + 1, + 2, + true, + Some(Attempt { + failed: false, + rewarded: false + }) + ), + Err(AcceptanceRefusal::AlreadyTaken) + ); + } + + #[test] + fn retry_still_applies_level_race_class_and_prerequisite_gates_in_order() { + let failed = Some(Attempt { + failed: true, + rewarded: false, + }); + assert_eq!( + acceptance(requirements(false), 9, 2, 1, false, failed), + Err(AcceptanceRefusal::Level) + ); + assert_eq!( + acceptance(requirements(false), 10, 2, 1, false, failed), + Err(AcceptanceRefusal::Race) + ); + assert_eq!( + acceptance(requirements(false), 10, 1, 1, false, failed), + Err(AcceptanceRefusal::Class) + ); + assert_eq!( + acceptance(requirements(false), 10, 1, 2, false, failed), + Err(AcceptanceRefusal::Prerequisite) + ); + } + + #[test] + fn reward_priority_is_explicit_then_imported_then_placeholder() { + assert_eq!(base_reward_xp(123, 270, 10), 123); + assert_eq!(base_reward_xp(0, 270, 10), 450); + assert_eq!(base_reward_xp(0, 0, 10), 490); + } +} diff --git a/crates/lyracore-shared/src/resistance.rs b/crates/lyracore-shared/src/resistance.rs new file mode 100644 index 000000000..6f7d76109 --- /dev/null +++ b/crates/lyracore-shared/src/resistance.rs @@ -0,0 +1,197 @@ +//! Resistance calculation shared by combat and the character sheet. + +use crate::spell::taxonomy::A_MOD_RESISTANCE; +use crate::{ + aura::{stacked_amount, AuraAmount}, + item_property as stat, +}; + +pub const SCHOOL_BITS: [u8; 7] = [1, 2, 4, 8, 16, 32, 64]; +pub const STAT_KINDS: [u8; 7] = [ + stat::ARMOR, + stat::HOLY_RESISTANCE, + stat::FIRE_RESISTANCE, + stat::NATURE_RESISTANCE, + stat::FROST_RESISTANCE, + stat::SHADOW_RESISTANCE, + stat::ARCANE_RESISTANCE, +]; + +#[derive(Clone, Copy, Debug)] +pub struct ResistanceItem { + pub slot: u8, + pub max_durability: u32, + pub durability: u32, + pub template: [i32; 7], + pub enchant_id: u32, + pub property_enchants: [u32; 3], +} + +fn school_index(school_mask: u8) -> Option { + let index = school_mask.trailing_zeros() as usize; + (index < SCHOOL_BITS.len()).then_some(index) +} + +pub fn aura_contribution(aura: AuraAmount, school_mask: u8) -> i32 { + if aura.kind == A_MOD_RESISTANCE && aura.parameter as u32 & u32::from(school_mask) != 0 { + stacked_amount(aura.amount, aura.stacks) + } else { + 0 + } +} + +/// A multi-school aura contributes once; equipment uses the lowest requested school bit. +/// Item and equipment sums saturate before the signed aura total is added, as in the Module. +pub fn resistance_bonus( + school_mask: u8, + auras: impl IntoIterator, + items: impl IntoIterator, + mut enchant_stat: impl FnMut(u32, u8) -> i32, +) -> i32 { + let aura: i32 = auras + .into_iter() + .map(|a| aura_contribution(a, school_mask)) + .sum(); + let gear = school_index(school_mask).map_or(0, |index| { + let kind = STAT_KINDS[index]; + stat::equipped_stat( + items + .into_iter() + .filter(|item| { + crate::item::grants_equipped_stats( + item.slot, + item.max_durability, + item.durability, + ) + }) + .map(|item| { + let enchant = if item.enchant_id == 0 { + 0 + } else { + enchant_stat(item.enchant_id, kind) + }; + let property = stat::property_stat(item.property_enchants, |id| { + if id == 0 { + 0 + } else { + enchant_stat(id, kind) + } + }); + ( + item.slot, + item.max_durability, + item.durability, + stat::item_stat(item.template[index], enchant, property), + ) + }), + ) + }); + aura.saturating_add(gear) +} + +pub fn effective_resistance(base: u32, bonus: i32) -> u32 { + (i64::from(base) + i64::from(bonus)).clamp(0, i64::from(u32::MAX)) as u32 +} + +pub fn positive_armor(auras: impl IntoIterator) -> u32 { + auras + .into_iter() + .map(|a| aura_contribution(a, SCHOOL_BITS[0])) + .filter(|&amount| amount > 0) + .fold(0u32, |sum, amount| sum.saturating_add(amount as u32)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chest() -> ResistanceItem { + ResistanceItem { + slot: 4, + max_durability: 100, + durability: 100, + template: [120, 1, 2, 3, 4, 5, 6], + enchant_id: 1, + property_enchants: [2, 3, 0], + } + } + + #[test] + fn resistance_combines_stack_masks_equipment_and_property_effects() { + let auras = [ + AuraAmount { + kind: A_MOD_RESISTANCE, + parameter: 5, + amount: 20, + stacks: 0, + }, + AuraAmount { + kind: A_MOD_RESISTANCE, + parameter: 1, + amount: -10, + stacks: 3, + }, + AuraAmount { + kind: 0, + parameter: 1, + amount: 500, + stacks: 1, + }, + ]; + let items = [ + chest(), + ResistanceItem { + slot: 23, + ..chest() + }, + ResistanceItem { + durability: 0, + ..chest() + }, + ResistanceItem { + slot: 18, + max_durability: 0, + durability: 0, + enchant_id: 0, + property_enchants: [0; 3], + template: [5; 7], + }, + ]; + let enchant = |id, kind| match (id, kind) { + (1, stat::ARMOR) => 11, + (2, stat::ARMOR) => 7, + (3, stat::ARMOR) => -2, + (2, stat::FIRE_RESISTANCE) => 9, + _ => 0, + }; + assert_eq!( + effective_resistance(40, resistance_bonus(1, auras, items, enchant)), + 171 + ); + assert_eq!(resistance_bonus(4, auras, items, enchant), 36); + assert_eq!(resistance_bonus(5, auras, items, enchant), 131); + assert_eq!(positive_armor(auras), 20); + } + + #[test] + fn signed_bonus_saturates_before_base_and_final_clamp() { + let item = ResistanceItem { + template: [i32::MAX; 7], + ..chest() + }; + let aura = AuraAmount { + kind: A_MOD_RESISTANCE, + parameter: 1, + amount: 10, + stacks: 1, + }; + assert_eq!( + effective_resistance(40, resistance_bonus(1, [aura], [item], |_, _| 0)), + i32::MAX as u32 + 40 + ); + assert_eq!(effective_resistance(40, -100), 0); + assert_eq!(effective_resistance(u32::MAX, 10), u32::MAX); + assert_eq!(resistance_bonus(0, [aura], [item], |_, _| 100), 0); + assert_eq!(resistance_bonus(128, [aura], [item], |_, _| 100), 0); + } +} diff --git a/crates/lyracore-shared/src/spell.rs b/crates/lyracore-shared/src/spell.rs index f8bee0f7b..99d16241f 100644 --- a/crates/lyracore-shared/src/spell.rs +++ b/crates/lyracore-shared/src/spell.rs @@ -1,8 +1,10 @@ //! Spell channel classification shared by gameplay and its protocol projection. -pub const SPELL_ATTR_CHANNELED: u32 = 0x0080; +pub mod taxonomy; + +pub use taxonomy::SPELL_ATTR_CHANNELED; /// Periodic triggers retain their legacy channel behavior when the header has no flag. pub fn is_channel_aura(effect_kind: u8, cast_flags: u32) -> bool { - effect_kind == 0x93 || cast_flags & SPELL_ATTR_CHANNELED != 0 + effect_kind == taxonomy::A_PERIODIC_TRIGGER || cast_flags & SPELL_ATTR_CHANNELED != 0 } diff --git a/crates/lyracore-shared/src/spell/taxonomy.rs b/crates/lyracore-shared/src/spell/taxonomy.rs new file mode 100644 index 000000000..caec779e2 --- /dev/null +++ b/crates/lyracore-shared/src/spell/taxonomy.rs @@ -0,0 +1,289 @@ +//! Durable spell effect codes and the parameter vocabulary shared by import and gameplay. + +macro_rules! effect_kinds { + ($($code:ident => $variant:ident = $value:literal),+ $(,)?) => { + /// A known effect code. Table columns retain their byte encoding. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[repr(u8)] + pub enum EffectKind { + $($variant = $value),+ + } + + impl EffectKind { + pub const ALL: &[Self] = &[$(Self::$variant),+]; + + pub const fn is_aura(self) -> bool { + self as u8 & KIND_AURA_BIT != 0 + } + } + + impl TryFrom for EffectKind { + type Error = u8; + + fn try_from(value: u8) -> Result { + match value { + $($value => Ok(Self::$variant),)+ + other => Err(other), + } + } + } + + // Byte names are retained for authored rows and the Package API. + $(pub const $code: u8 = EffectKind::$variant as u8;)+ + }; +} + +effect_kinds! { + E_DAMAGE => Damage = 0x01, + E_HEAL => Heal = 0x02, + E_ENERGIZE => Energize = 0x03, + E_DISPEL => Dispel = 0x04, + E_TRIGGER => Trigger = 0x05, + E_TAUNT => Taunt = 0x06, + E_CREATE_ITEM => CreateItem = 0x07, + E_WEAPON_STRIKE => WeaponStrike = 0x08, + E_CHARGE => Charge = 0x09, + E_CONVERT_RESOURCE => ConvertResource = 0x0A, + E_JUDGEMENT => Judgement = 0x0B, + E_ADD_COMBO => AddCombo = 0x0C, + E_FINISHER_DAMAGE => FinisherDamage = 0x0D, + E_RESURRECT => Resurrect = 0x0E, + E_SCRIPTED => Scripted = 0x0F, + E_PICKPOCKET => Pickpocket = 0x10, + E_INTERRUPT => Interrupt = 0x11, + E_REDUCE_THREAT => ReduceThreat = 0x12, + E_NEXT_SWING => NextSwing = 0x13, + E_SET_STANCE => SetStance = 0x14, + E_SUMMON_PET => SummonPet = 0x15, + E_HEAL_MAX_HEALTH => HealMaxHealth = 0x16, + E_ENCHANT_ITEM => EnchantItem = 0x17, + E_DISENCHANT => Disenchant = 0x18, + E_POWER_BURN => PowerBurn = 0x19, + E_BLINK => Blink = 0x1A, + E_PERSISTENT_AREA => PersistentArea = 0x1B, + E_FISH => Fish = 0x1C, + E_OPEN_LOCK => OpenLock = 0x1D, + E_RECALL_HOME => RecallHome = 0x1F, + E_TAME_CREATURE => TameCreature = 0x20, + E_FEED_PET => FeedPet = 0x21, + E_DUEL => Duel = 0x22, + E_DISMOUNT => Dismount = 0x23, + E_SUMMON_HOSTILE => SummonHostile = 0x24, + E_DISTRACT => Distract = 0x25, + E_SELF_RESURRECT => SelfResurrect = 0x26, + A_PERIODIC_DAMAGE => AuraPeriodicDamage = 0x90, + A_PERIODIC_HEAL => AuraPeriodicHeal = 0x91, + A_PERIODIC_ENERGIZE => AuraPeriodicEnergize = 0x92, + A_PERIODIC_TRIGGER => AuraPeriodicTrigger = 0x93, + A_MOD_STAT => AuraModStat = 0xA0, + A_MOD_RESISTANCE => AuraModResistance = 0xA1, + A_ABSORB => AuraAbsorb = 0xA2, + A_MOD_COMBAT => AuraModCombat = 0xA3, + A_MOD_SPEED => AuraModSpeed = 0xA4, + A_MOD_HEALTH_POWER => AuraModHealthPower = 0xA5, + A_MOD_DAMAGE_TAKEN => AuraModDamageTaken = 0xA6, + A_SEAL => AuraSeal = 0xA7, + A_STEALTH => AuraStealth = 0xA8, + A_COMBAT_HEALTH_REGEN_PCT => AuraCombatHealthRegenPct = 0xA9, + A_MOD_STAT_PCT => AuraModStatPct = 0xAA, + A_PROC_TRIGGER => AuraProcTrigger = 0xAB, + A_SPELLMOD_FLAT => AuraSpellmodFlat = 0xAC, + A_SPELLMOD_PCT => AuraSpellmodPct = 0xAD, + A_DISARM => AuraDisarm = 0xAE, + A_RETALIATE => AuraRetaliate = 0xAF, + A_CONTROL => AuraControl = 0xB0, + A_IMMUNITY => AuraImmunity = 0xB1, + A_MOD_DETECT_RANGE => AuraModDetectRange = 0xB2, + A_MOUNTED => AuraMounted = 0xB3, + A_PROC_DAMAGE => AuraProcDamage = 0xB4, + A_SELF_RESURRECT => AuraSelfResurrect = 0xB5, + A_FLAG => AuraFlag = 0xBE, +} + +pub const KIND_AURA_BIT: u8 = 0x80; +pub const SPELL_ATTR_PASSIVE: u32 = 0x40; +pub const SPELL_ATTR_REQ_BEHIND: u32 = 0x0001; +pub const SPELL_ATTR_REQ_STEALTH: u32 = 0x0002; +pub const SPELL_ATTR_STEALTH_SAFE: u32 = 0x0004; +pub const SPELL_ATTR_FINISHER_DURATION: u32 = 0x0008; +pub const SPELL_ATTR_INCAP_OPENER: u32 = 0x0010; +pub const SPELL_ATTR_REQ_OVERPOWER: u32 = 0x0020; +pub const SPELL_ATTR_REQ_REVENGE: u32 = 0x0040; +pub const SPELL_ATTR_REQ_DAGGER: u32 = 0x0100; +pub const HUMANOID_TYPE: u8 = 7; +pub const BEAST_TYPE: u8 = 1; +pub const CRITTER_TYPE: u8 = 8; +pub const M_STUN: i32 = 1; +pub const M_ROOT: i32 = 2; +pub const M_FEAR: i32 = 3; +pub const M_POLY: i32 = 4; +pub const P_NONE: u8 = 0; +pub const P_STAT_ID: u8 = 1; +pub const P_SCHOOL_MASK: u8 = 2; +pub const P_MECHANIC: u8 = 3; +pub const P_POWER_TYPE: u8 = 4; +pub const P_COMBAT_FIELD: u8 = 5; +pub const P_SPEED_KIND: u8 = 6; +pub const P_FLAG: u8 = 7; +pub const P_ITEM_ENTRY: u8 = 8; +pub const P_ENTRY: u8 = 9; +pub const P_ENCHANT_ID: u8 = 10; +pub const SPELLMOD_OP_DAMAGE: i32 = 0; +pub const SPELLMOD_OP_CASTING_TIME: i32 = 10; +pub const P_SPELLMOD_OP: u8 = 11; +pub const P_PCT_MAX_POWER: u8 = 12; +pub const P_GAMEOBJECT_ENTRY: u8 = 13; +pub const P_DISPLAY_ID: u8 = 14; +pub const P_SPELL_ID: u8 = 15; +pub const P_FLAT_MANA: u8 = 16; +pub const P_RAW: u8 = 255; +pub const T_SELF: u8 = 0; +pub const T_TARGET_ENEMY: u8 = 1; +pub const T_TARGET_ALLY: u8 = 2; +pub const T_TARGET_ANY: u8 = 3; +pub const T_AREA_ENEMY: u8 = 4; +pub const T_AREA_ALLY: u8 = 5; +pub const T_CHAIN_ENEMY: u8 = 6; +pub const T_SCRIPTED: u8 = 7; +pub const COMBAT_ATTACK_POWER: u8 = 0; +pub const COMBAT_CRIT: u8 = 1; +pub const COMBAT_HIT: u8 = 2; +pub const COMBAT_DMG_DONE: u8 = 3; +pub const COMBAT_SPELL_POWER: u8 = 5; +pub const COMBAT_PARRY: u8 = 6; +pub const COMBAT_DODGE: u8 = 7; +pub const COMBAT_BLOCK: u8 = 8; +pub const COMBAT_DEFENSE: u8 = 9; +pub const COMBAT_THREAT: u8 = 10; +pub const SPEED_MOVE: u8 = 0; +pub const SPEED_SWING: u8 = 1; +pub const SPEED_CAST: u8 = 2; +pub const SPEED_MOUNTED: u8 = 3; +pub const STANCE_BATTLE: u8 = 0; +pub const STANCE_DEFENSIVE: u8 = 1; +pub const STANCE_BERSERKER: u8 = 2; +pub const STANCE_BEAR: u8 = 3; +pub const STANCE_CAT: u8 = 4; +pub const STANCE_DIRE_BEAR: u8 = 5; +pub const STAT_STR: u8 = 0; +pub const STAT_AGI: u8 = 1; +pub const STAT_STA: u8 = 2; +pub const STAT_INT: u8 = 3; +pub const STAT_SPI: u8 = 4; +pub const STAT_ALL: u8 = 0xFF; +pub const RESIST_ARMOR: u8 = 0x01; +pub const SPELL_ATTR_CHANNELED: u32 = 0x0080; +pub const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_codes_round_trip_and_unknown_codes_are_returned() { + for kind in EffectKind::ALL { + assert_eq!(EffectKind::try_from(*kind as u8), Ok(*kind)); + } + assert_eq!(EffectKind::try_from(0), Err(0)); + assert_eq!(EffectKind::try_from(0x1E), Err(0x1E)); + assert_eq!(EffectKind::try_from(0xFF), Err(0xFF)); + } + #[test] + fn kind_wire_values() { + assert_eq!(KIND_AURA_BIT, 0x80); + assert_eq!(E_DAMAGE, 0x01); + assert_eq!(E_HEAL, 0x02); + assert_eq!(E_DISPEL, 0x04); + assert_eq!(A_PERIODIC_DAMAGE, 0x90); + assert_eq!(A_PERIODIC_HEAL, 0x91); + assert_eq!(A_ABSORB, 0xA2); + assert_eq!(A_MOD_COMBAT, 0xA3); + assert_eq!(A_FLAG, 0xBE); + // every aura kind has the high bit; every instant kind does not. + for k in [ + E_DAMAGE, E_HEAL, E_ENERGIZE, E_DISPEL, E_TRIGGER, E_SCRIPTED, + ] { + assert_eq!( + k & KIND_AURA_BIT, + 0, + "instant kind 0x{k:02x} must not set the aura bit" + ); + } + for k in [ + A_PERIODIC_DAMAGE, + A_PERIODIC_HEAL, + A_MOD_COMBAT, + A_FLAG, + A_CONTROL, + ] { + assert_ne!( + k & KIND_AURA_BIT, + 0, + "aura kind 0x{k:02x} must set the aura bit" + ); + } + } + + #[test] + fn param_tag_wire_values_exhaustive() { + assert_eq!(P_NONE, 0); + assert_eq!(P_STAT_ID, 1); + assert_eq!(P_SCHOOL_MASK, 2); + assert_eq!(P_MECHANIC, 3); + assert_eq!(P_POWER_TYPE, 4); + assert_eq!(P_COMBAT_FIELD, 5); + assert_eq!(P_SPEED_KIND, 6); + assert_eq!(P_FLAG, 7); + assert_eq!(P_ITEM_ENTRY, 8); + assert_eq!(P_ENTRY, 9); + assert_eq!(P_ENCHANT_ID, 10); + assert_eq!(P_PCT_MAX_POWER, 12); + assert_eq!(P_GAMEOBJECT_ENTRY, 13); + assert_eq!(P_DISPLAY_ID, 14); + assert_eq!(P_SPELL_ID, 15); + assert_eq!(P_FLAT_MANA, 16); + assert_eq!(P_RAW, 255); + let all = [ + P_NONE, + P_STAT_ID, + P_SCHOOL_MASK, + P_MECHANIC, + P_POWER_TYPE, + P_COMBAT_FIELD, + P_SPEED_KIND, + P_FLAG, + P_ITEM_ENTRY, + P_ENTRY, + P_ENCHANT_ID, + P_PCT_MAX_POWER, + P_GAMEOBJECT_ENTRY, + P_DISPLAY_ID, + P_SPELL_ID, + P_FLAT_MANA, + P_RAW, + ]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "param tags must be distinct: {a} vs {b}"); + } + } + } + + #[test] + fn mechanic_wire_values() { + assert_eq!(M_STUN, 1); + assert_eq!(M_ROOT, 2); + assert_eq!(M_FEAR, 3); + assert_eq!(M_POLY, 4); + // All distinct + non-zero (0 stays the inert "no mechanic" value). + let ms = [M_STUN, M_ROOT, M_FEAR, M_POLY]; + for (i, a) in ms.iter().enumerate() { + assert_ne!(*a, 0, "mechanic must be non-zero (0 = inert sentinel)"); + for b in &ms[i + 1..] { + assert_ne!(a, b, "mechanics must be distinct"); + } + } + // A_CONTROL is an aura kind (the gates read it off a frozen game_aura row). + assert_eq!(A_CONTROL & KIND_AURA_BIT, KIND_AURA_BIT); + } +} diff --git a/crates/lyracore-shared/src/talent.rs b/crates/lyracore-shared/src/talent.rs index 8d03d930e..38333dd47 100644 --- a/crates/lyracore-shared/src/talent.rs +++ b/crates/lyracore-shared/src/talent.rs @@ -7,3 +7,42 @@ pub fn respec_cost_copper(respec_count: u32) -> u32 { n => 100_000 + 50_000 * (n - 2), } } + +pub fn points_available(level: u32, spent: u32) -> u32 { + level.saturating_sub(9).saturating_sub(spent) +} + +/// Rank zero is unlearned; absent higher ranks use the base spell. +pub fn rank_spell(rank: u8, spells: [u32; 5]) -> u32 { + if rank == 0 { + return 0; + } + let selected = spells[usize::from(rank.min(5) - 1)]; + if selected == 0 { + spells[0] + } else { + selected + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_points_remain_unsigned_and_never_underflow() { + assert_eq!(points_available(9, 0), 0); + assert_eq!(points_available(10, 0), 1); + assert_eq!(points_available(60, 40), 11); + assert_eq!(points_available(60, 52), 0); + assert_eq!(points_available(u32::MAX, 0), 4_294_967_286); + } + + #[test] + fn rank_zero_is_unlearned_and_higher_ranks_use_rank_five_or_the_base() { + assert_eq!(rank_spell(0, [100, 200, 300, 400, 500]), 0); + assert_eq!(rank_spell(2, [100, 200, 300, 400, 500]), 200); + assert_eq!(rank_spell(6, [100, 200, 300, 400, 500]), 500); + assert_eq!(rank_spell(5, [100, 0, 0, 0, 0]), 100); + } +} diff --git a/crates/lyracore-shared/src/trainer.rs b/crates/lyracore-shared/src/trainer.rs index bd79ca634..8f3fc80ba 100644 --- a/crates/lyracore-shared/src/trainer.rs +++ b/crates/lyracore-shared/src/trainer.rs @@ -178,3 +178,168 @@ mod tests { assert_eq!(trainer_type::PETS, 3); } } + +#[derive(Clone, Copy)] +pub struct LearnEffect { + pub index: u8, + pub id: u64, + pub kind: u8, + pub trigger_spell: u32, +} + +pub fn learn_target_trigger(kind: u8, trigger_spell: u32) -> Option { + use crate::spell::taxonomy::{ + A_FLAG, A_PERIODIC_TRIGGER, A_PROC_DAMAGE, A_PROC_TRIGGER, E_TRIGGER, + }; + (trigger_spell != 0 + && ![ + A_PERIODIC_TRIGGER, + A_FLAG, + A_PROC_TRIGGER, + A_PROC_DAMAGE, + E_TRIGGER, + ] + .contains(&kind)) + .then_some(trigger_spell) +} + +/// Resolve a wrapper independently of table iteration order. +pub fn resolve_learn_target(spell_id: u32, effects: impl IntoIterator) -> u32 { + effects + .into_iter() + .filter_map(|effect| { + learn_target_trigger(effect.kind, effect.trigger_spell) + .map(|target| (effect.index, effect.id, target)) + }) + .min_by_key(|&(index, id, _)| (index, id)) + .map_or(spell_id, |(_, _, target)| target) +} + +pub fn is_combat_skill_line(line: u32) -> bool { + matches!( + line, + 44 | 172 | 54 | 160 | 229 | 43 | 55 | 136 | 473 | 173 | 162 | 95 + ) +} + +/// Combat skills are known by row presence; profession and riding tiers compare caps. +pub fn offering_known( + skill_line: u32, + offered_cap: u32, + stored_cap: Option, + knows_spell: bool, +) -> bool { + if skill_line == 0 { + knows_spell + } else if is_combat_skill_line(skill_line) { + stored_cap.is_some() + } else { + stored_cap.is_some_and(|cap| cap >= offered_cap) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OfferingState { + Known, + LevelTooLow, + Available, +} + +pub fn offering_state(known: bool, level: u32, required_level: u32) -> OfferingState { + if known { + OfferingState::Known + } else if level < required_level { + OfferingState::LevelTooLow + } else { + OfferingState::Available + } +} + +#[cfg(test)] +mod rule_tests { + use super::*; + + #[test] + fn wrapper_resolution_orders_eligible_effects_by_index_then_id() { + let effects = [ + LearnEffect { + index: 2, + id: 1, + kind: 0, + trigger_spell: 100, + }, + LearnEffect { + index: 1, + id: 8, + kind: 0, + trigger_spell: 200, + }, + LearnEffect { + index: 1, + id: 7, + kind: 0, + trigger_spell: 300, + }, + ]; + assert_eq!(resolve_learn_target(1873, effects), 300); + assert_eq!(resolve_learn_target(1873, effects.into_iter().rev()), 300); + } + + #[test] + fn wrapper_resolution_ignores_all_trigger_and_proc_kinds() { + let excluded = [0x93, 0xBE, 0xAB, 0xB4, 0x05]; + for kind in excluded { + let effect = LearnEffect { + index: 0, + id: 1, + kind, + trigger_spell: 639, + }; + assert_eq!(resolve_learn_target(1873, [effect]), 1873, "kind {kind:#x}"); + } + assert_eq!( + resolve_learn_target( + 1873, + [LearnEffect { + index: 0, + id: 1, + kind: 0, + trigger_spell: 0 + }] + ), + 1873 + ); + assert_eq!( + resolve_learn_target( + 1873, + [LearnEffect { + index: 0, + id: 1, + kind: 0, + trigger_spell: 639 + }] + ), + 639 + ); + } + + #[test] + fn weapon_proficiency_uses_row_presence_while_riding_and_professions_use_tiers() { + assert!(offering_known(173, 300, Some(1), false)); + assert!(!offering_known(173, 0, None, true)); + for line in [185, 762] { + assert!(offering_known(line, 75, Some(75), false)); + assert!(!offering_known(line, 150, Some(75), false)); + assert!(!offering_known(line, 75, None, true)); + } + assert!(offering_known(0, 0, None, true)); + assert!(!offering_known(0, 0, Some(300), false)); + } + + #[test] + fn known_offerings_stay_gray_even_below_the_required_level() { + assert_eq!(offering_state(true, 1, 10), OfferingState::Known); + assert_eq!(offering_state(false, 9, 10), OfferingState::LevelTooLow); + assert_eq!(offering_state(false, 10, 10), OfferingState::Available); + } +} diff --git a/docs/package-api.md b/docs/package-api.md index bccbcd190..ff2509225 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -1,4 +1,4 @@ -# Package API, version 3 +# Package API, version 4 The Package API is the part of the Module a Package may name. A Package compiles into the Module wasm and can reach any `crate::` path the compiler resolves; this document says which of those paths @@ -26,6 +26,13 @@ The Package API is a compatibility contract, not a sandbox. Compiled Package cod Module with full access to every table, exactly as `lyracore packages add`'s Trust Review states. The lint tells a Package author when core moved under them; it stops nothing at run time. +## Spell effect kinds + +`crate::actor::EffectKind` defines the known effect codes shared by the Module, Gateway and +Importer. Convert a row's `kind` byte with `EffectKind::try_from`; handle unknown authored codes +explicitly. Existing Actor byte constants keep their values. Version 4 retains the five roots +and Actor operations from version 3. + ## The surface ### Marker macros diff --git a/docs/recipes/add-a-spell-effect.md b/docs/recipes/add-a-spell-effect.md index a2ff3807e..cad945837 100644 --- a/docs/recipes/add-a-spell-effect.md +++ b/docs/recipes/add-a-spell-effect.md @@ -7,8 +7,7 @@ touched 24 files, three of them generated bindings. Read that diff next to this git diff e3858119^1 e3858119 --stat ``` -The steps run in this order because each one depends on the one before it. The spell effect -taxonomy has three hand-kept copies today, so step 2 and step 3 must agree exactly. +The shared taxonomy supplies the codes used by the Module, Gateway and Importer. ## 1. State the 1.12.1 behaviour @@ -17,24 +16,21 @@ cooldown and header attributes from the client's `Spell.dbc`, and what vmangos o the effect. This is the **Expected (1.12.1)** part of the issue, and the tests in later steps assert these values. -## 2. Add the kind to the Module taxonomy +## 2. Add the kind to the shared taxonomy -File: `module/src/spell/taxonomy.rs`. +File: `crates/lyracore-shared/src/spell/taxonomy.rs`. -- Add `pub(crate) const E_: u8` at the next free value, with a doc comment that names the raw - effect and what its amount means. An instant kind sits below `0x80`. An aura kind sets - `KIND_AURA_BIT`. -- The value is append-only. Stored `game_spell_effect` rows carry it, so never renumber a kind. -- Add the kind to `ALL_INSTANT_KINDS` or `ALL_AURA_KINDS`. - -File: `module/src/spell/tests.rs`. Raise the count in `instant_kind_wire_values_exhaustive` (or its -aura twin) by one and pin the new value, for example `assert_eq!(E_DISTRACT, 0x25)`. +- Add the byte name, enum variant and code to `effect_kinds!`. An instant kind sits below `0x80`; + an aura kind sets `KIND_AURA_BIT`. +- Codes are append-only. Stored `game_spell_effect` rows retain their byte encoding. +- Add a literal encoding assertion for the new code. The macro supplies conversion and iteration; + no separate kind list or count needs an edit. ## 3. Map the raw effect in the importer File: `importer/src/spell.rs`. -- Add the same `E_` constant with the same value. +- Import the new byte name from `lyracore_shared::spell::taxonomy`. - Map the raw effect id in `instant_effect_to_kind` (or the aura mapping) and name the kind in `kind_name`. - If the effect needs parameters, set them in `resolve_instant_params`. If it needs a header @@ -56,7 +52,8 @@ Files: `module/src/spell/cast/resolve.rs`, `module/src/spell/outcome.rs`. ## 5. Apply the effect -File: `module/src/spell/cast/targeting.rs`. Add one arm to `apply_effect` that calls the owning +File: `module/src/spell/cast/targeting.rs`. The exhaustive `EffectKind` match in `apply_effect` +requires an arm for the new variant. Call the owning family. The behaviour lives in that family's module, not in the arm. Distract calls `crate::creatures::distraction::distract`, a new module declared in `module/src/creatures/mod.rs`. @@ -85,9 +82,11 @@ the binding check compares the complete generated tree. - Run `scripts/check-gateway-bindings.py`. It must report no difference. - Review the diff under `gateway/src/stdb/bindings/`. Distract added `creature_distraction_type.rs`, `game_creature_distraction_table.rs` and lines in `mod.rs`. -- If the Gateway subscribes to the table, add its query to `coordinator_queries` in - `gateway/src/stdb/connection.rs`, and add a `parity_test!` and the table name to - `MANIFEST_TABLES` in `gateway/tests/schema_parity.rs`. +- If the table drives a Relay, declare it in `gateway/src/codec//subscriptions.rs`. The + declaration generates its query and row callbacks. A table used only by Durable Reads goes in + `coordinator_queries` in `gateway/src/stdb/connection.rs`. +- Add a `parity_test!` and the subscribed table name to `MANIFEST_TABLES` in + `gateway/tests/schema_parity.rs`. The inventory covers both query sources. ## 8. Change the packets, if the client needs it diff --git a/docs/recipes/add-an-opcode.md b/docs/recipes/add-an-opcode.md index 23d1f893a..6d81f63f9 100644 --- a/docs/recipes/add-an-opcode.md +++ b/docs/recipes/add-an-opcode.md @@ -53,9 +53,11 @@ Required for every new table and every new reducer. - Run the `spacetime generate` command in [`danger-zones.md`](../danger-zones.md) section 1.2 and restore the hand-patched names and the comment it lists. - Run `scripts/check-gateway-bindings.py`. It must report no difference. -- For each table the Gateway subscribes to, add its `SELECT * FROM ` to - `coordinator_queries` in `gateway/src/stdb/connection.rs`, then add a `parity_test!` and the table - name to `MANIFEST_TABLES` in `gateway/tests/schema_parity.rs`. +- For a Relay table, add its declaration beside its codec as described in step 11. The declaration + generates the subscription query. For a table used only by Durable Reads, add its query to + `coordinator_queries` in `gateway/src/stdb/connection.rs`. +- Add a `parity_test!` and the table name to `MANIFEST_TABLES` in + `gateway/tests/schema_parity.rs`. Its inventory checks both query sources. ## 5. Call the reducer from the Store trait method @@ -135,15 +137,22 @@ byte, opcode and size header included. ## 11. Relay the row -File: `gateway/src/stdb/world_view.rs`. - -- Register the callbacks with `wire_insert` and `wire_delete` in `register_shard_callbacks` for a - World Shard table, or in `arm_realm_private` for a Realm-core one. -- In the callback, find the recipient's viewer by key, for example - `view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid))`, and queue the packet with - `enqueue(viewer, move |viewer| vec![...])`. The job runs on that session's writer, in order. -- If a client that enters the world must see a running state, send it from the world entry sweep as - well. The Instance Removal countdown does this. +File: `gateway/src/codec//subscriptions.rs`, declared by its codec module. + +- Add a `declare_relays!` entry with the generated table accessor, its Realm-core choice, and each + insert, update or delete callback. It generates the query and per-Shard registration together. + Set the Realm-core choice to `true` only for a table that also needs the private Realm-core Relay. +- Choose `insert(Live)` to suppress initial subscription rows or `insert(Snapshot)` to replay them. + Updates and deletes always carry live deltas. Follow the table's behavior; event names do not + determine its replay policy. +- Keep the row callback and outbound builder in this file. Find the recipient with a WorldView + routing method, clone rows before queuing, then call the builder inside + `enqueue(viewer, move |viewer| vec![...])`. The World Session writer runs that job in order. +- A new Protocol Family joins `protocol_relays!` in `gateway/src/stdb/relays.rs`. It then contributes + both its queries and its callbacks. WorldView keeps the shared AOI and viewer lifetime, and + registers callbacks before seeding the cache on reconnect. +- If world entry must show resident state, add it to the world entry sweep too. The Instance Removal + countdown does this. ## 12. Test each seam @@ -151,7 +160,7 @@ File: `gateway/src/stdb/world_view.rs`. `ReducerCallError::refused` and the client gets the Refusal answer. One answers `ReducerCallError::transport_lost` and the dispatcher returns `Err`. - The packet bytes, in the codec file. -- The Relay's recipient choice and packet, in the `world_view.rs` test modules. +- The Relay builder in its codec file, and shared audience and queue behavior in `world_view.rs`. - The Module rule, in a durable test `module/tests/.rs`. CI picks up a new target with no workflow edit. - A cross-Shard Gateway behaviour, in an ignored test under `gateway/src/stdb/`. CI runs those one by diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index d6928708a..0b3f090d0 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -319,3 +319,5 @@ mod tests { assert_eq!(body.len(), 19 + text.len()); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/addon/subscriptions.rs b/gateway/src/codec/addon/subscriptions.rs new file mode 100644 index 000000000..328abd226 --- /dev/null +++ b/gateway/src/codec/addon/subscriptions.rs @@ -0,0 +1,33 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::Outbound; + +/// Build one addon-language whisper using the addressed viewer as its sender. +pub(crate) fn addon_message_outbound(self_guid: u64, row: &AddonMessage) -> Vec { + let text = codec::addon::build_bridge_envelope(&row.cmd, &row.payload); + let (opcode, body) = codec::addon::build_addon_smsg_raw(self_guid, &text); + vec![Outbound::Raw { opcode, body }] +} + +/// Addon messages are identity-addressed, with the addressed viewer as the wire sender. +pub(crate) fn addon_message_appeared(view: &WorldView, shard: ShardId, row: &AddonMessage) { + let Some(viewer) = + view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) + else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |viewer| { + crate::codec::addon::subscriptions::addon_message_outbound(viewer.self_guid, &row) + }); +} + +crate::stdb::relays::declare_relays! { + game_addon_message [false] { + insert(Live) => |context, _event, row| addon_message_appeared(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/auction.rs b/gateway/src/codec/auction.rs index da84f40b9..ef5630eea 100644 --- a/gateway/src/codec/auction.rs +++ b/gateway/src/codec/auction.rs @@ -319,3 +319,5 @@ mod tests { assert_eq!(bytes, expected); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/auction/subscriptions.rs b/gateway/src/codec/auction/subscriptions.rs new file mode 100644 index 000000000..37c5df9d0 --- /dev/null +++ b/gateway/src/codec/auction/subscriptions.rs @@ -0,0 +1,95 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +use wow_world_messages::vanilla::AuctionHouse; + +/// Auction Notice: the packet body both legs run. Audience resolved by the caller. Outbid and Won +/// go to the bidder on `SMSG_AUCTION_BIDDER_NOTIFICATION`; Sold, Expired and New bid go to the +/// owner on `SMSG_AUCTION_OWNER_NOTIFICATION`; Removed goes to the bidder a Cancellation displaced +/// on `SMSG_AUCTION_REMOVED_NOTIFICATION` (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`). +/// `house` outside the imported 1-7 range and any other `kind` both drop the notice and log — +/// the accompanying Auction Mail still reaches the recipient's inbox either way. +pub(crate) fn auction_notice_outbound(row: &AuctionNotice) -> Vec { + use lyracore_shared::auction::auction_notice::{EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON}; + + let Ok(house) = AuctionHouse::try_from(row.house) else { + log::warn!( + "auction notice: house {} is not an imported auction house; dropping kind {}", + row.house, + row.kind + ); + return Vec::new(); + }; + match row.kind { + OUTBID | WON => vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_BIDDER_NOTIFICATION(Box::new( + codec::build_auction_bidder_notification( + house, + row.auction_id, + row.bidder_guid, + row.bid, + row.out_bid, + row.item_entry, + row.random_property_id, + ), + )), + )], + SOLD | EXPIRED | NEW_BID => vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_OWNER_NOTIFICATION(Box::new( + codec::build_auction_owner_notification( + row.auction_id, + row.bid, + row.out_bid, + row.bidder_guid, + row.item_entry, + row.random_property_id, + ), + )), + )], + REMOVED => vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_REMOVED_NOTIFICATION(Box::new( + codec::build_auction_removed_notification( + row.auction_id, + row.item_entry, + row.random_property_id, + ), + )), + )], + other => { + log::warn!("auction notice: unknown kind {other}; dropping"); + Vec::new() + } + } +} + +/// An Auction Notice landed → SMSG_AUCTION_BIDDER_NOTIFICATION or SMSG_AUCTION_OWNER_NOTIFICATION +/// to the row's RECIPIENT and nobody else (same shape as [`group_event_appeared`]). An offline +/// recipient gets the Auction Mail the same transaction wrote and no packet — the vanilla rule. +pub(crate) fn auction_notice_appeared(view: &WorldView, row: &AuctionNotice) { + let Some(session) = view.session_of_owner(row.recipient_guid) else { + return; + }; + let Some(viewer) = view.viewer(session) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) { + return; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::auction::subscriptions::auction_notice_outbound(&row) + }); +} + +crate::stdb::relays::declare_relays! { + game_auction_notice [true] { + insert(Live) => |context, _event, row| auction_notice_appeared(&context.view, row); + } +} diff --git a/gateway/src/codec/breath.rs b/gateway/src/codec/breath.rs index c6bff7a23..645989e8c 100644 --- a/gateway/src/codec/breath.rs +++ b/gateway/src/codec/breath.rs @@ -40,3 +40,5 @@ pub fn build_drowning_damage_log(character_guid: u64, damage: u32) -> ServerOpco resist: 0, })) } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/breath/subscriptions.rs b/gateway/src/codec/breath/subscriptions.rs new file mode 100644 index 000000000..94fdbf419 --- /dev/null +++ b/gateway/src/codec/breath/subscriptions.rs @@ -0,0 +1,53 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::Outbound; + +/// Shared-dispatch recipient leg for a breath relay row. The owner-session lookup in +/// `world_view::breath_relay_appeared` has already established the self-only audience. +pub(crate) fn relay_breath_event( + character_guid: u64, + kind: u8, + time_remaining_ms: u32, + duration_ms: u32, + damage: u32, +) -> Vec { + let msg = match kind { + 0 => codec::build_breath_timer_start(time_remaining_ms, duration_ms), + 1 => codec::build_breath_timer_stop(), + 2 => codec::build_drowning_damage_log(character_guid, damage), + unknown => { + log::warn!("breath relay: dropping unknown kind {unknown} for {character_guid}"); + return Vec::new(); + } + }; + vec![Outbound::One(msg)] +} + +/// Send breath events to their owner on the callback's Shard. +pub(crate) fn breath_relay_appeared(view: &WorldView, shard: ShardId, row: &BreathRelayEvent) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (guid, kind, remaining, duration, damage) = ( + row.character_guid, + row.kind, + row.time_remaining_ms, + row.duration_ms, + row.damage, + ); + enqueue(viewer.clone(), move |_| { + crate::codec::breath::subscriptions::relay_breath_event( + guid, kind, remaining, duration, damage, + ) + }); +} + +crate::stdb::relays::declare_relays! { + game_breath_relay_event [false] { + insert(Snapshot) => |context, _event, row| breath_relay_appeared(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/channel.rs b/gateway/src/codec/channel.rs index 91e9171c2..0baa1c75e 100644 --- a/gateway/src/codec/channel.rs +++ b/gateway/src/codec/channel.rs @@ -218,3 +218,5 @@ mod tests { assert_eq!(&wire[4..], expected.as_slice()); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/channel/subscriptions.rs b/gateway/src/codec/channel/subscriptions.rs new file mode 100644 index 000000000..2f4bd931b --- /dev/null +++ b/gateway/src/codec/channel/subscriptions.rs @@ -0,0 +1,53 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use std::sync::Arc; + +/// A Channel Notice: one raw `SMSG_CHANNEL_NOTIFY` for one recipient. The Relay already chose the +/// audience. +pub(crate) fn channel_notice_outbound(row: &ChatChannelNoticeEvent) -> Vec { + let (opcode, body) = codec::channel::notify(&codec::channel::ChannelNoticeView { + notice: row.notice, + channel_name: row.channel_name.clone(), + subject_guid: row.subject_guid, + actor_guid: row.actor_guid, + old_flags: row.old_flags, + new_flags: row.new_flags, + channel_flags: row.channel_flags, + text: row.text.clone(), + }); + vec![Outbound::Raw { opcode, body }] +} + +/// A Channel Notice landed → one `SMSG_CHANNEL_NOTIFY` per recipient with a World Session on this +/// Gateway, whatever Shard it plays on. The Module chose the audience; no ignore list applies. +pub(crate) fn channel_notice_appeared(view: &WorldView, row: &ChatChannelNoticeEvent) { + let row = Arc::new(row.clone()); + for &recipient in &row.recipients { + let Some(viewer) = view + .session_of_owner(recipient) + .and_then(|session| view.viewer(session)) + else { + continue; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + recipient, + viewer.self_guid, + ) { + continue; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::channel::subscriptions::channel_notice_outbound(&row) + }); + } +} + +crate::stdb::relays::declare_relays! { + game_chat_channel_notice_event [true] { + insert(Live) => |context, _event, row| channel_notice_appeared(&context.view, row); + } +} diff --git a/gateway/src/codec/combat.rs b/gateway/src/codec/combat.rs index 7f3534328..3cbf27015 100644 --- a/gateway/src/codec/combat.rs +++ b/gateway/src/codec/combat.rs @@ -686,3 +686,5 @@ mod spellmod_tests { } } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs new file mode 100644 index 000000000..848c669c8 --- /dev/null +++ b/gateway/src/codec/combat/subscriptions.rs @@ -0,0 +1,1268 @@ +//! Table Relays for this Protocol Family. +use lyracore_shared::spell::taxonomy::{A_MOD_RESISTANCE, A_MOD_SPEED, SPEED_MOVE}; + +use crate::codec::entity::subscriptions::build_peer_create; +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::{CellKey, EntityLayer, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use spacetimedb_sdk::Table; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Stealth presence marker (taxonomy `A_STEALTH`): a unit carrying ≥1 such aura is stealthed and must +/// be hidden from OTHER players' clients. Mirrors `module::spell::taxonomy::A_STEALTH = 0xA8`. +pub(crate) use lyracore_shared::spell::taxonomy::A_STEALTH; + +/// The visibility action a single A_STEALTH aura insert/delete implies, decided from the POST-change +/// count of A_STEALTH auras on the stealther's guid. A Stealth cast applies TWO A_STEALTH auras (and a +/// break removes both); since the SDK applies the whole row-delta before firing callbacks, both inserts +/// fire seeing count 2 and both deletes seeing count 0 — so we HIDE on ANY insert while stealthed +/// (count >= 1) and REVEAL when the count reaches 0. Pure + unit-tested; the exactly-once guarantee +/// against an already-hidden/visible peer is the per-viewer `created` set at the call site, not a boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StealthAction { + Hide, + Reveal, + None, +} + +/// Hide on a stealth insert and reveal when the last marker is removed. The aura index supplies +/// the count after this callback's change; the viewer's `created` set makes repeated actions harmless. +pub(crate) fn stealth_action(is_insert: bool, post_change_count: usize) -> StealthAction { + match (is_insert, post_change_count) { + (true, n) if n >= 1 => StealthAction::Hide, + (false, 0) => StealthAction::Reveal, + _ => StealthAction::None, + } +} + +/// Relay a visible melee swing or ranged launch. The Module emits ranged damage at impact +/// through `SpellCastEvent`, which `cast_event_outbound` relays separately. +pub(crate) fn combat_event_outbound( + created: &Mutex>, + row: &CombatEvent, +) -> Vec { + if !created.lock().unwrap().contains(&row.attacker_guid) { + return Vec::new(); + } + let mut out = Vec::new(); + // A ranged launch uses SPELL_GO. ATTACKERSTATEUPDATE would animate a melee swing. + if row.ranged_spell_id != 0 { + // Auto Shot stamps an ammo display id → the AMMO flag fires the arrow graphic (24 = + // INVTYPE_AMMO). Wand Shoot (ammo_display_id 0) → no flag → the bolt. Deliberate + // simplification: inv-type 24 is hardcoded. + let ammo = if row.ammo_display_id != 0 { + Some((row.ammo_display_id, 24)) + } else { + None + }; + let miss = row.hit_info == 2; // module HIT_MISS + let go = codec::build_spell_go_outcome( + row.attacker_guid, + row.ranged_spell_id, + row.target_guid, + ammo, + miss, + ); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(Box::new( + go, + )))); + } else if !row.spell_swing { + // A fired on-next-swing spell (Heroic Strike/Cleave) REPLACES the white hit — the + // whole swing rides the spell's cast-event row (GO + yellow named damage log), so this + // event sends NO white ATTACKERSTATEUPDATE. killing_blow/ATTACKSTOP below still applies. + let m = codec::build_attacker_state_update( + row.attacker_guid, + row.target_guid, + row.damage, + row.hit_info, + row.blocked_amount, + 0, + ); + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(m)), + )); + } + // C2: on a MELEE killing blow, tell the attacker to leave combat stance. The target itself + // vanishes via the game_world_entity on_delete → SMSG_DESTROY_OBJECT relay. A RANGED kill + // sends no ATTACKSTOP (the client was never in melee-attack state; vanilla sends none) — + // its stop signal is the SMSG_CANCEL_AUTO_REPEAT from the engagement-row delete relay + // (kill_creature's disengage frees the attacker's row). + if row.killing_blow && row.ranged_spell_id == 0 { + let stop = codec::build_attack_stop(row.attacker_guid, row.target_guid); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( + Box::new(stop), + ))); + } + out +} + +/// Combat-stance engage leg: SMSG_ATTACKSTART for a melee engagement — +/// the one body both legs run. Ranged rows are NOT melee combat (Auto Shot: ATTACKSTART would animate a +/// melee swing between shots); the `created` gate skips an out-of-scope attacker like the swing +/// relay. +pub(crate) fn melee_engage_outbound( + created: &Mutex>, + row: &MeleeAttack, +) -> Vec { + if row.ranged_spell_id != 0 { + return Vec::new(); + } + if !created.lock().unwrap().contains(&row.attacker_guid) { + return Vec::new(); + } + let m = codec::build_attack_start(row.attacker_guid, row.target_guid); + vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( + Box::new(m), + ))] +} + +/// Combat-stance disengage leg. A RANGED row's delete is the ONE +/// server-initiated auto-repeat teardown choke point: the OWNING player (and only them) gets the +/// 0-byte SMSG_CANCEL_AUTO_REPEAT so its toggle drops in lockstep (Auto Shot); a melee row's delete is +/// SMSG_ATTACKSTOP for everyone (a non-kill disengage leaves stance too). +pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec { + if row.ranged_spell_id != 0 { + if row.attacker_guid == self_guid { + return vec![Outbound::One(ServerOpcodeMessage::SMSG_CANCEL_AUTO_REPEAT)]; + } + return Vec::new(); + } + let m = codec::build_attack_stop(row.attacker_guid, row.target_guid); + vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( + Box::new(m), + ))] +} + +/// Spell cast visuals: the one body both paths run. It handles START and GO sequencing, interrupt +/// teardown, pushback, Proc logs, damage and heal logs, and cooldown. Every caster-private branch +/// keys on `self_guid`. +pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec { + let mut out = Vec::new(); + let Some(kind) = SpellCastEventKind::decode(row) else { + return out; + }; + // INTERRUPT signal: the victim's mid-cast timed spell was cancelled. + // Relay SMSG_SPELL_FAILURE{spell, Interrupted} to the caster so the client tears down its cast + // bar. No SMSG_SPELL_GO follows because the cast never resolved. + if kind == SpellCastEventKind::Interrupt { + // SELF-ONLY for a PLAYER caster: game_spell_cast_event is a global public subscription, + // so this closure fires for EVERY player. SMSG_SPELL_FAILURE is a caster-private cast-bar + // teardown (unlike the START/GO broadcast visuals), so relay it ONLY to the caster — else + // bystanders get stray "Interrupted" feedback and a bystander mid-casting the same spell + // risks a spurious teardown. + // A creature or pet caster (0xF130/0xF140 high-guid) has no self — broadcast so every observer's + // mob cast bar tears down on a Kick/Counterspell (the packet carries the mob's guid, so + // a bystander's own bar is untouched; mirrors the START broadcast that drew the bar). + let is_creature = matches!(row.caster_guid >> 48, 0xF130 | 0xF140); + if is_creature || row.caster_guid == self_guid { + let m = codec::build_spell_failure(row.caster_guid, row.spell_id); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE( + Box::new(m), + ))); + } + // A row that names a concrete reason (an on-next-swing strike that could not pay its cost at + // the swing) follows the teardown with the failed cast result, in vanilla's order + // (SendInterrupted then SendCastResult). The client needs BOTH: the teardown closes the cast + // bar, the cast result releases the ability it holds as its current melee spell and prints + // the red "Not enough rage" line. Caster-private, like the teardown above — the raw encoder, + // because the typed SMSG_CAST_RESULT inverts the success/failure semantics. + if row.caster_guid == self_guid { + if let Some(reason) = codec::cast_failure_reason_for_code(row.failure_reason) { + out.push(Outbound::Raw { + opcode: 0x0130, + body: codec::build_cast_result_failed(row.spell_id, reason), + }); + } + } + return out; + } + // PUSHBACK signal: a direct hit moved the fire time of an in-progress timed cast. Broadcast + // SMSG_SPELL_DELAYED so anyone watching the caster's cast bar sees the same shift. + if kind == SpellCastEventKind::Pushback { + let m = codec::build_spell_delayed(row.caster_guid, row.delay_ms); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED( + Box::new(m), + ))); + return out; + } + // Proc and ranged-impact rows report damage without another cast animation or cooldown. + if kind == SpellCastEventKind::ProcLog { + if row.damage > 0 { + let log = codec::build_spell_non_melee_damage_log( + row.target_guid, + row.caster_guid, + row.spell_id, + row.damage, + row.school, + row.is_crit, + row.resisted, + row.absorbed, + ); + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), + )); + } + return out; + } + if kind == SpellCastEventKind::Start { + // Cast-START (a timed spell): SMSG_SPELL_START with the cast-bar duration so observers + // see the bar FILL. The GO/COOLDOWN follow on the cast-GO COMPLETION event. + let start = + codec::build_spell_start(row.caster_guid, row.spell_id, row.cast_time_ms, 0, None); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( + Box::new(start), + ))); + return out; + } + // Cast-GO. Legacy rows reach this branch when cast_time_ms is zero. Sequence: + // - GENUINE INSTANT (is_completion=false): START(0)+GO — SendSpellStart fires for EVERY + // non-triggered cast (timer 0 for an instant) to register the pending cast, then cast() → + // SendSpellGo finalizes it. START flags = 0x02, GO flags = 0x0100 (set in the builders). + // - TIMED COMPLETION (is_completion=true): GO ALONE — the begin already sent START(cast_time); + // a 2nd START(0) reset the bar to zero-length ("stuck on full"). The SMSG_SPELL_GO is the + // client's cast finalizer (it matches by caster guid + spell id to release the pending cast). + // The CASTER's OWN instant cast (cast_time 0 → this GO branch, !is_completion) already got + // START+GO SYNCHRONOUSLY from the CMSG_CAST_SPELL handler (so they precede the aura effects the + // SDK's alphabetical callback order would otherwise send first). Skip the duplicate to the caster + // — but still relay it to OBSERVERS (caster != self) and for TIMED COMPLETIONS (is_completion, + // which the gateway did NOT sync-send). NOTE: a player's TRIGGERED instant cast (channel-tick + // missile / on-hit trigger) also matches !is_completion && caster==self and is suppressed here — + // it did not get a synchronous send, so it loses its caster-side visual until the client_initiated + // flag lands (tracked follow-up). Acceptable for the slice; observers still see it. + // Suppress ONLY what the CMSG handler actually sent synchronously — the row says so + // (client_initiated rides from the cast_spell reducer path alone). The old shape + // (!is_completion && self) also swallowed the caster's TRIGGERED instants — channel-tick + // missiles (Arcane Missiles/Drain Life), on-hit trigger_spell procs — which never had a + // synchronous send, so their caster saw no visual while observers did. + let caster_synced_instant = row.client_initiated && row.caster_guid == self_guid; + if !caster_synced_instant { + if !row.is_completion { + let start = codec::build_spell_start(row.caster_guid, row.spell_id, 0, 0, None); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( + Box::new(start), + ))); + } + // CAST_RESULT(OK) is caster-only: clears the pending spell state so the subsequent + // GO can release m_currentSpells. Only for timed completions — the instant-cast + // caster got it synchronously from the CMSG_CAST_SPELL handler. + if row.is_completion && row.caster_guid == self_guid { + out.push(Outbound::Raw { + opcode: 0x0130, + body: codec::build_cast_result_ok(row.spell_id), + }); + } + let mut go = codec::build_spell_go(row.caster_guid, row.spell_id, row.target_guid, None); + // A 0-damage on-next-swing FIRE that rode a missed/dodged/parried swing reports + // the outcome in the GO's miss list — the client prints the yellow "Your Heroic + // Strike missed/was dodged/was parried" line (the white MISS was suppressed via + // spell_swing). swing_hit_info uses the CombatEvent codes (2 miss, 3 dodge, 4 parry); + // every non-swing row carries 0, keeping this branch dead for ordinary casts. + if row.is_completion && row.damage == 0 { + use wow_world_messages::vanilla::{SpellMiss, SpellMissInfo}; + let miss_info = match row.swing_hit_info { + 2 => Some(SpellMissInfo::Miss), + 3 => Some(SpellMissInfo::Dodge), + 4 => Some(SpellMissInfo::Parry), + _ => None, + }; + if let Some(mi) = miss_info { + go.hits.clear(); + go.misses = vec![SpellMiss { + target: wow_world_messages::Guid::new(row.target_guid), + miss_info: mi, + }]; + } + } + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(Box::new( + go, + )))); + } + + if row.damage > 0 { + let log = codec::build_spell_non_melee_damage_log( + row.target_guid, + row.caster_guid, + row.spell_id, + row.damage, + row.school, + row.is_crit, + row.resisted, + row.absorbed, + ); + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), + )); + } + // The green floating heal number + combat-log line — SMSG_SPELLHEALLOG whenever + // this cast restored health (module sums EFFECTIVE heal onto the row; overheal-only + // casts carry 0 and stay silent, matching the damage gate's shape). + if row.healed > 0 { + use wow_world_messages::vanilla::SMSG_SPELLHEALLOG; + use wow_world_messages::Guid; + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELLHEALLOG( + Box::new(SMSG_SPELLHEALLOG { + victim: Guid::new(row.target_guid), + caster: Guid::new(row.caster_guid), + id: row.spell_id, + damage: row.healed, + critical: row.is_crit, + }), + ))); + } + + // SMSG_SPELL_GO releases pending casts. A zero-duration cooldown can leave the button stuck. + if row.cooldown_ms > 0 { + let cd = codec::build_spell_cooldown(row.caster_guid, row.spell_id, row.cooldown_ms); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_COOLDOWN( + Box::new(cd), + ))); + } + out +} + +/// The full aura-array VALUES block for `target_guid`. `auras` is that unit's current aura set +/// ([`crate::stdb::world_view::AuraIndex::on_target`]); the filter only guards a caller that +/// hands over a wider set. +pub(crate) fn aura_sync( + auras: impl Iterator, + target_guid: u64, +) -> Outbound { + let slots: Vec = auras + .filter(|a| a.target_guid == target_guid) + .map(|a| codec::update_mask::AuraSlot { + slot: a.slot, + spell_id: a.spell_id, + flags: a.flags, + level: a.level, + }) + .collect(); + let mask = codec::update_mask::full_aura_mask(&slots); + let (opcode, body) = codec::build_values_update_raw(target_guid, &mask); + Outbound::Raw { opcode, body } +} + +// A self resistance aura applies or expires after the entity CREATE. Recompute all seven effective +// resistances from the coordinator's post-change cache and push the complete paperdoll group. +pub(crate) fn armor_packet( + coord: &Coordinator, + changed: &Aura, + self_guid: u64, +) -> Option { + const RESISTANCE_MASK: u32 = 0x7f; + if changed.target_guid != self_guid + || changed.eff_kind != A_MOD_RESISTANCE + || (changed.eff_p0 as u32 & RESISTANCE_MASK) == 0 + { + return None; + } + // The coordinator owns the entity, aura, item-instance and item-catalogue caches needed by this fold. + let guard = coord.0.coord(); + let db = &guard.conn.db; + let eff = crate::stdb::armor::effective_resistances(db, self_guid); + // Carry the positive AURA portion alongside the total so the paperdoll renders the + // green "(+N)" (Devotion Aura showed as plain white armor). Raw path — the positive + // field has no gtker setter. Login self-corrects through this same relay (the SDK + // replays aura rows after subscription-apply). + let pos = crate::stdb::armor::aura_armor_positive(db, self_guid); + Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_armor_values(self_guid, eff, pos)), + ))) +} + +// The Module has already persisted sheet stats in the aura transaction. Re-push that row +// only for aura kinds that affect it; the paperdoll is private to its owner. +pub(crate) fn sheet_packet( + coord: &Coordinator, + changed: &Aura, + self_guid: u64, +) -> Option { + let moves_sheet = lyracore_shared::aura::moves_sheet(changed.eff_kind, changed.eff_p0); + if changed.target_guid != self_guid || !moves_sheet { + return None; + } + let guard = coord.0.coord(); + let db = &guard.conn.db; + let st = crate::stdb::armor::sheet_stats(db, self_guid); + drop(guard); + st.map(|st| { + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_sheet_stats_values(self_guid, &st), + ))) + }) +} + +/// Hide a peer on stealth gain; reveal it with current equipment and auras on stealth loss. +/// The AOI and `created` checks prevent distant or duplicate CREATEs. Self never hides from itself. +#[allow(clippy::too_many_arguments)] +pub(crate) fn stealth_visibility( + post_change_stealth_count: usize, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + session: u64, + coord: &Coordinator, + created: &Arc>>, + changed: &Aura, + self_guid: u64, + is_insert: bool, +) -> Vec { + if changed.eff_kind != A_STEALTH || changed.target_guid == self_guid { + return Vec::new(); + } + match stealth_action(is_insert, post_change_stealth_count) { + StealthAction::Hide => { + // Evict so a later REVEAL (and ordinary AOI re-entry) re-CREATEs; only DESTROY if the + // viewer actually had it (idempotent — a never-created peer needs no DESTROY). + if created.lock().unwrap().remove(&changed.target_guid) { + vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(changed.target_guid), + ))] + } else { + Vec::new() + } + } + StealthAction::Reveal => { + // Re-CREATE only if not currently shown (idempotent) AND the peer is in this viewer's + // scope (its row is in the cache). Insert into `created` BEFORE building so the dedup + // is consistent; if the row is gone (out of scope), undo the insert and emit nothing. + if !created.lock().unwrap().insert(changed.target_guid) { + return Vec::new(); + } + + if !view + .spatial + .can_see(EntityLayer::WorldEntity, session, changed.target_guid) + { + created.lock().unwrap().remove(&changed.target_guid); + return Vec::new(); + } + match coord + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&changed.target_guid) + { + Some(row) => match build_peer_create(coord, view, shard, self_guid, &row) { + Some(out) => out, + None => { + // Encode failure: roll the dedup entry back like `offer_peer_create` + // does, else this guid is permanently suppressed (marked created with + // no CREATE ever sent) — the same latent re-entry bug fixed on the + // insert/update path. + created.lock().unwrap().remove(&changed.target_guid); + Vec::new() + } + }, + None => { + created.lock().unwrap().remove(&changed.target_guid); + Vec::new() + } + } + } + StealthAction::None => Vec::new(), + } +} + +/// Channel timers belong to the caster, including channels whose auras sit on an enemy. +pub(crate) fn spell_channel_outbound( + coord: &Coordinator, + self_guid: u64, + row: &Aura, + removed: bool, +) -> Option { + use wow_world_messages::vanilla::{MSG_CHANNEL_START_Server, MSG_CHANNEL_UPDATE_Server}; + if row.caster_guid != self_guid { + return None; + } + let guard = coord.0.coord(); + let db = &guard.conn.db; + let is_channel = |aura: &Aura| { + let flags = db + .game_spell() + .spell_id() + .find(&aura.spell_id) + .map_or(0, |spell| spell.cast_flags); + lyracore_shared::spell::is_channel_aura(aura.eff_kind, flags) + }; + if !is_channel(row) { + return None; + } + let active = db + .game_aura() + .iter() + .filter(|aura| aura.caster_guid == self_guid && is_channel(aura)) + .min_by_key(|aura| aura.id); + let packet = if removed { + if active.is_some() { + return None; + } + ServerOpcodeMessage::MSG_CHANNEL_UPDATE(MSG_CHANNEL_UPDATE_Server { time: 0 }) + } else { + if active.as_ref().is_none_or(|active| active.id != row.id) { + return None; + } + let duration = ((row.expires_at.to_micros_since_unix_epoch() + - row.applied_at.to_micros_since_unix_epoch()) + / 1000) + .clamp(0, u32::MAX as i64) as u32; + ServerOpcodeMessage::MSG_CHANNEL_START(MSG_CHANNEL_START_Server { + spell: row.spell_id, + duration, + }) + }; + Some(Outbound::One(packet)) +} + +/// Aura insert leg: array sync, self-only duration/run-speed/armor/sheet packets, and the +/// stealth HIDE transition. The target's current aura set comes from the gateway's aura index, +/// post-change like the cache it mirrors; `stealth_count` was taken on the pump. +#[allow(clippy::too_many_arguments)] +pub(crate) fn aura_insert_outbound( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + session: u64, + created: &Arc>>, + self_guid: u64, + row: &Aura, + stealth_count: usize, +) -> Vec { + let mut out = Vec::new(); + out.extend(spell_channel_outbound(coord, self_guid, row, false)); + // A stealth-hidden peer (not in `created`) must get NO per-peer relay: a partial VALUES on + // a DESTROYed object is a client crash/desync vector. + let visible = + row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); + let current = visible.then(|| view.auras.on_target(shard, row.target_guid)); + if let Some(current) = ¤t { + out.push(aura_sync(current.iter().cloned(), row.target_guid)); + } + if let Some(o) = aura_duration_packet(row, self_guid) { + out.push(o); + } + if let Some(current) = current { + if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { + out.push(o); + } + if let Some(o) = armor_packet(coord, row, self_guid) { + out.push(o); + } + if let Some(o) = sheet_packet(coord, row, self_guid) { + out.push(o); + } + } + out.extend(stealth_visibility( + stealth_count, + view, + shard, + session, + coord, + created, + row, + self_guid, + true, + )); + out +} + +/// Aura update leg (no stealth transition on an update). +pub(crate) fn aura_update_outbound( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + created: &Arc>>, + self_guid: u64, + row: &Aura, + expires_changed: bool, +) -> Vec { + let mut out = Vec::new(); + let visible = + row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); + let current = visible.then(|| view.auras.on_target(shard, row.target_guid)); + if let Some(current) = ¤t { + out.push(aura_sync(current.iter().cloned(), row.target_guid)); + } + // Re-send the timer only when the duration window actually changed (a refresh). + if expires_changed { + if let Some(o) = aura_duration_packet(row, self_guid) { + out.push(o); + } + } + if let Some(current) = current { + if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { + out.push(o); + } + if let Some(o) = armor_packet(coord, row, self_guid) { + out.push(o); + } + if let Some(o) = sheet_packet(coord, row, self_guid) { + out.push(o); + } + } + out +} + +/// Aura delete leg (the REVEAL half of stealth). The aura index is post-delete, so the +/// sync/speed folds read the remaining set. +#[allow(clippy::too_many_arguments)] +pub(crate) fn aura_delete_outbound( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + session: u64, + created: &Arc>>, + self_guid: u64, + row: &Aura, + stealth_count: usize, +) -> Vec { + let mut out = Vec::new(); + out.extend(spell_channel_outbound(coord, self_guid, row, true)); + let visible = + row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); + if visible { + let current = view.auras.on_target(shard, row.target_guid); + out.push(aura_sync(current.iter().cloned(), row.target_guid)); + if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { + out.push(o); + } + if let Some(o) = armor_packet(coord, row, self_guid) { + out.push(o); + } + if let Some(o) = sheet_packet(coord, row, self_guid) { + out.push(o); + } + } + out.extend(stealth_visibility( + stealth_count, + view, + shard, + session, + coord, + created, + row, + self_guid, + false, + )); + out +} + +/// Reconnect sends one current aura array per target and observer. Owner timers remain per aura, +/// while speed, armor, and sheet values refresh once even when several auras changed offline. +pub(crate) fn aura_snapshot_outbound( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + viewer: &Viewer, + target_guid: u64, + previous: &[Aura], +) -> Vec { + let current = view.auras.on_target(shard, target_guid); + let stealth_count = current + .iter() + .filter(|row| row.eff_kind == A_STEALTH) + .count(); + let mut out = Vec::new(); + if let Some(stealth) = current + .iter() + .chain(previous) + .find(|row| row.eff_kind == A_STEALTH) + { + out.extend(stealth_visibility( + stealth_count, + view, + shard, + viewer.session, + coord, + &viewer.created, + stealth, + viewer.self_guid, + stealth_count > 0, + )); + // Reveal includes the aura array; hide must not send VALUES after DESTROY. + if !out.is_empty() { + return out; + } + } + if target_guid != viewer.self_guid && !viewer.created.lock().unwrap().contains(&target_guid) { + return out; + } + out.push(aura_sync(current.iter().cloned(), target_guid)); + if target_guid != viewer.self_guid { + return out; + } + out.extend( + current + .iter() + .filter_map(|row| aura_duration_packet(row, viewer.self_guid)), + ); + out.extend( + current + .iter() + .chain(previous) + .find_map(|row| run_speed_packet(current.iter().cloned(), row, viewer.self_guid)), + ); + out.extend( + current + .iter() + .chain(previous) + .find_map(|row| armor_packet(coord, row, viewer.self_guid)), + ); + out.extend( + current + .iter() + .chain(previous) + .find_map(|row| sheet_packet(coord, row, viewer.self_guid)), + ); + out +} + +/// Projectile impact: the floating damage number for a projectile that +/// finished its travel — never a START/GO. Pure over the row; broadcast. +pub(crate) fn impact_event_outbound(row: &SpellImpactEvent) -> Vec { + if row.damage == 0 { + return Vec::new(); // a fully-absorbed impact (or a vanished target) logs nothing + } + let log = codec::build_spell_non_melee_damage_log( + row.target_guid, + row.caster_guid, + row.spell_id, + row.damage, + row.school, + row.is_crit, + row.resisted, + row.absorbed, + ); + vec![Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), + )] +} + +/// Dynamic objects: the shared-dispatch twin of the per-player +/// `on_dynobj_insert` relay. The instance gate already ran in `world_view::dynobj_appeared`. +pub(crate) fn relay_dynobj_create(row: &DynamicObject) -> Vec { + let m = codec::build_dynamicobject_create_object( + row.guid, + row.caster_guid, + row.spell_id, + row.x, + row.y, + row.z, + row.radius_yd, + ); + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(m), + ))] +} + +/// A bare SMSG_DESTROY_OBJECT — the delete leg of the dynobj/corpse families, shared because the +/// packet is nothing but the guid. +pub(crate) fn relay_destroy_object(guid: u64) -> Vec { + vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(guid), + ))] +} + +/// SMSG_UPDATE_AURA_DURATION (buff timer): the 1.12 UpdateMask aura array carries NO duration, +/// so without this the client shows "0 seconds"/flashing icon. Only for THIS player's own auras +/// (the slot indexes the player's aura array) with a finite window; the duration is the full +/// applied span (expires - applied), which is what the client shows on apply/refresh. Pure → +/// unit-tested (hoisted verbatim out of `subscribe_player_events`, no logic change). +pub(crate) fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option { + if row.target_guid != self_guid { + return None; + } + // i64::MAX is the infinite-duration sentinel; no timer packet for toggle auras. + if row.expires_at.to_micros_since_unix_epoch() == i64::MAX { + return None; + } + let ms = (row.expires_at.to_micros_since_unix_epoch() + - row.applied_at.to_micros_since_unix_epoch()) + / 1000; + if ms <= 0 { + return None; + } + Some(Outbound::One( + ServerOpcodeMessage::SMSG_UPDATE_AURA_DURATION(codec::build_aura_duration( + row.slot, ms as u32, + )), + )) +} + +/// A movement aura change refreshes the owner's speed from its current movement and mount auras. +/// The complete fold also applies stacks and the Module's minimum movement speed. +pub(crate) fn run_speed_packet( + auras: impl Iterator, + changed: &Aura, + self_guid: u64, +) -> Option { + const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s + if changed.target_guid != self_guid + || changed.eff_kind != A_MOD_SPEED + || changed.eff_p0 != i32::from(SPEED_MOVE) + { + return None; + } + let speed = lyracore_shared::aura::run_speed( + BASE_RUN_SPEED, + auras + .filter(|aura| aura.target_guid == self_guid) + .map(|aura| lyracore_shared::aura::AuraAmount { + kind: aura.eff_kind, + parameter: aura.eff_p0, + amount: aura.amount, + stacks: aura.stacks, + }), + ); + Some(Outbound::One( + ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new(codec::build_force_run_speed( + self_guid, speed, + ))), + )) +} + +/// A ground-area dynamic object appeared (Consecration's swirl etc.) → CREATE for every +/// same-instance viewer on the row's shard — the same `instance_relay_gate` the per-player leg +/// runs. Short-lived, so no login/AOI resident sweep (a mid-area login misses the visual), +/// exactly as before. +pub(crate) fn dynobj_appeared(view: &WorldView, shard: ShardId, row: &DynamicObject) { + let row = Arc::new(row.clone()); + for viewer in viewers_on_shard(view, shard) { + if !crate::codec::entity::subscriptions::instance_relay_gate( + row.instance_id, + Some(viewer.instance_id), + ) { + continue; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::relay_dynobj_create(&row) + }); + } +} + +/// A dynamic object's area ended → DESTROY to every viewer on the shard. Deliberately NOT +/// instance-gated: the per-player delete leg never was (a DESTROY for a guid the client doesn't +/// know is a no-op), and the A/B contract is byte-for-byte equality. +pub(crate) fn dynobj_vanished(view: &WorldView, shard: ShardId, row: &DynamicObject) { + let guid = row.guid; + for viewer in viewers_on_shard(view, shard) { + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::relay_destroy_object(guid) + }); + } +} + +pub(crate) fn combat_audience( + view: &WorldView, + shard: ShardId, + row: &CombatEvent, +) -> Vec> { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + view.cell_audience( + shard, + Some(key), + BOX_HALF_SPAN, + &[row.attacker_guid, row.target_guid], + ) +} + +/// Queue a ranged launch or melee swing for each viewer. The job checks its created set. +pub(crate) fn combat_event_appeared(view: &WorldView, shard: ShardId, row: &CombatEvent) { + let row = Arc::new(row.clone()); + for viewer in combat_audience(view, shard, &row) { + let row = row.clone(); + enqueue(viewer.clone(), move |viewer| { + crate::codec::combat::subscriptions::combat_event_outbound(&viewer.created, &row) + }); + } +} + +/// An engagement row names no cell; the attacker's indexed cell anchors it. +pub(crate) fn melee_audience( + view: &WorldView, + shard: ShardId, + row: &MeleeAttack, +) -> Vec> { + let key = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, row.attacker_guid, shard); + view.cell_audience( + shard, + key, + BOX_HALF_SPAN, + &[row.attacker_guid, row.target_guid], + ) +} + +/// A melee/ranged engagement began → SMSG_ATTACKSTART per viewer (`melee_engage_outbound` runs +/// the ranged skip + created gate in the job). +pub(crate) fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { + let row = Arc::new(row.clone()); + for viewer in melee_audience(view, shard, &row) { + let row = row.clone(); + let created = viewer.created.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::melee_engage_outbound(&created, &row) + }); + } +} + +/// An engagement row was removed → ATTACKSTOP / the owner-only CANCEL_AUTO_REPEAT +/// (`melee_disengage_outbound`, keyed on each viewer's own guid). +pub(crate) fn melee_disengaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { + let row = Arc::new(row.clone()); + for viewer in melee_audience(view, shard, &row) { + let row = row.clone(); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::melee_disengage_outbound(self_guid, &row) + }); + } +} + +/// Aura descriptors follow the target's AOI; channel timers also reach the caster. +pub(crate) fn aura_audience(view: &WorldView, shard: ShardId, row: &Aura) -> Vec> { + let key = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, row.target_guid, shard); + view.cell_audience( + shard, + key, + BOX_HALF_SPAN, + &[row.target_guid, row.caster_guid], + ) +} + +/// An aura appeared → per viewer: array sync + self-only packets + the stealth HIDE transition +/// (`aura_insert_outbound`, all gates inside the job against that viewer's own state). +pub(crate) fn aura_applied( + view: &Arc, + coord: &Coordinator, + shard: ShardId, + row: &Aura, + stealth_count: usize, +) { + let row = Arc::new(row.clone()); + for viewer in aura_audience(view, shard, &row) { + let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::combat::subscriptions::aura_insert_outbound( + &coord, + &view, + shard, + viewer.session, + &viewer.created, + viewer.self_guid, + &row, + stealth_count, + ) + }); + } +} + +/// An aura changed → per viewer: array sync + refresh-only duration + self-only packets. +pub(crate) fn aura_updated( + view: &Arc, + coord: &Coordinator, + shard: ShardId, + row: &Aura, + expires_changed: bool, +) { + let row = Arc::new(row.clone()); + for viewer in aura_audience(view, shard, &row) { + let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::combat::subscriptions::aura_update_outbound( + &coord, + &view, + shard, + &viewer.created, + viewer.self_guid, + &row, + expires_changed, + ) + }); + } +} + +/// An aura expired/was removed → per viewer: array sync + self-only packets + the stealth REVEAL +/// transition. +pub(crate) fn aura_removed( + view: &Arc, + coord: &Coordinator, + shard: ShardId, + row: &Aura, + stealth_count: usize, +) { + let row = Arc::new(row.clone()); + for viewer in aura_audience(view, shard, &row) { + let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::combat::subscriptions::aura_delete_outbound( + &coord, + &view, + shard, + viewer.session, + &viewer.created, + viewer.self_guid, + &row, + stealth_count, + ) + }); + } +} + +pub(crate) fn cast_audience( + view: &WorldView, + shard: ShardId, + row: &SpellCastEvent, +) -> Vec> { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + view.cell_audience( + shard, + Some(key), + BOX_HALF_SPAN, + &[row.caster_guid, row.target_guid], + ) +} + +/// A cast event landed → the full cast-lock sequence per viewer (`cast_event_outbound`: the +/// caster-private branches key on each viewer's own guid inside the job). +pub(crate) fn cast_event_appeared(view: &WorldView, shard: ShardId, row: &SpellCastEvent) { + let row = Arc::new(row.clone()); + for viewer in cast_audience(view, shard, &row) { + let row = row.clone(); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::cast_event_outbound(self_guid, &row) + }); + } +} + +pub(crate) fn impact_audience( + view: &WorldView, + shard: ShardId, + row: &SpellImpactEvent, +) -> Vec> { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + view.cell_audience( + shard, + Some(key), + BOX_HALF_SPAN, + &[row.caster_guid, row.target_guid], + ) +} + +/// A projectile impact landed → the floating damage number per viewer that can see the caster. +pub(crate) fn impact_appeared(view: &WorldView, shard: ShardId, row: &SpellImpactEvent) { + let row = Arc::new(row.clone()); + for viewer in impact_audience(view, shard, &row) { + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::impact_event_outbound(&row) + }); + } +} + +crate::stdb::relays::declare_relays! { + game_dynamic_object [false] { + insert(Snapshot) => |context, _event, row| dynobj_appeared(&context.view, context.shard, row); + delete(Snapshot) => |context, _event, row| dynobj_vanished(&context.view, context.shard, row); + } + game_combat_event [false] { + insert(Snapshot) => |context, _event, row| combat_event_appeared(&context.view, context.shard, row); + } + game_melee_attack [false] { + insert(Snapshot) => |context, _event, row| melee_engaged(&context.view, context.shard, row); + delete(Snapshot) => |context, _event, row| melee_disengaged(&context.view, context.shard, row); + } + game_spell_cast_event [false] { + insert(Snapshot) => |context, _event, row| cast_event_appeared(&context.view, context.shard, row); + } + game_spell_impact_event [false] { + insert(Snapshot) => |context, _event, row| impact_appeared(&context.view, context.shard, row); + } + game_aura [false] { + insert(Snapshot) => |context, _event, row| { context.view.auras.upsert(context.shard, row); let count = context.view.auras.stealth_count(context.shard, row.target_guid); aura_applied(&context.view, &context.coord, context.shard, row, count); }; + update(Snapshot) => |context, _event, old, row| { context.view.auras.upsert(context.shard, row); aura_updated(&context.view, &context.coord, context.shard, row, old.expires_at != row.expires_at); }; + delete(Snapshot) => |context, _event, row| { context.view.auras.remove(context.shard, row); let count = context.view.auras.stealth_count(context.shard, row.target_guid); aura_removed(&context.view, &context.coord, context.shard, row, count); }; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SpellCastEventKind { + Start, + Go, + Interrupt, + Pushback, + ProcLog, +} + +impl SpellCastEventKind { + /// Decode the append-only Module code. Kind zero is a row from the old schema and keeps the + /// former field-based order. An unknown nonzero kind is newer or damaged data, so guessing a + /// client packet would be unsafe. + pub(crate) fn decode(row: &SpellCastEvent) -> Option { + match row.kind { + 0 if row.is_interrupted => Some(Self::Interrupt), + 0 if row.delay_ms > 0 => Some(Self::Pushback), + 0 if row.is_proc_log => Some(Self::ProcLog), + 0 if row.cast_time_ms > 0 => Some(Self::Start), + 0 => Some(Self::Go), + 1 => Some(Self::Start), + 2 => Some(Self::Go), + 3 => Some(Self::Interrupt), + 4 => Some(Self::Pushback), + 5 => Some(Self::ProcLog), + unknown => { + log::warn!( + "spell cast relay: unknown kind {unknown} (event {}, caster {}, spell {})", + row.id, + row.caster_guid, + row.spell_id + ); + None + } + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + /// A minimal `Aura` row for the packet-builder tests: `target_guid`/`eff_kind`/`eff_p0`/`amount` + /// are the fields those builders branch on; `applied_at`/`expires_at` (raw micros) drive the + /// duration math. Everything else is a fixed, inert value. + pub(crate) fn aura( + target_guid: u64, + eff_kind: u8, + eff_p0: i32, + amount: i32, + applied_at_micros: i64, + expires_at_micros: i64, + ) -> Aura { + Aura { + id: 1, + target_guid, + caster_guid: target_guid, + spell_id: 6673, + slot: 3, + level: 1, + flags: 0, + applied_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(applied_at_micros), + expires_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(expires_at_micros), + effect_id: 1, + eff_kind, + amount, + eff_p0, + eff_p0_kind: 0, + eff_p1: 0, + period_ms: 0, + amount_remaining: 0, + stacks: 1, + next_tick_micros: 0, + channel_target: 0, + enters_combat: false, + proc_flags: 0, + proc_chance: 0, + proc_ppm: 0.0, + proc_ex: 0, + proc_school_mask: 0, + proc_family_name: 0, + proc_family_flags: 0, + proc_charges: 0, + proc_icd_ms: 0, + proc_ready_micros: 0, + } + } + + #[test] + fn aura_duration_packet_pins_the_remaining_window_for_self_only() { + let self_guid = 1u64; + // A 5-second remaining window on the player's OWN aura, in slot 3. + let mine = aura(self_guid, 0xA1, 1, 10, 0, 5_000_000); + let out = aura_duration_packet(&mine, self_guid); + let m = match out { + Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_AURA_DURATION(m))) => m, + _ => panic!("expected Some(Outbound::One(SMSG_UPDATE_AURA_DURATION))"), + }; + assert_eq!(m, codec::build_aura_duration(3, 5000)); + // A peer's aura (not self) never gets a duration packet — the slot only indexes the + // viewer's OWN aura array. + assert!(aura_duration_packet(&mine, self_guid + 1).is_none()); + // The infinite-duration sentinel (a toggle aura) never gets a timer packet. + let toggle = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); + assert!(aura_duration_packet(&toggle, self_guid).is_none()); + // A non-positive remaining window (expires_at <= applied_at) emits nothing. + let expired = aura(self_guid, 0xA1, 1, 10, 5_000_000, 5_000_000); + assert!(aura_duration_packet(&expired, self_guid).is_none()); + } + + #[test] + fn run_speed_packet_sums_move_speed_auras_on_self_only() { + let self_guid = 1u64; + // Two move-speed auras on self (e.g. Sprint 25% + a totem 5%) sum into ONE force-speed packet. + let a1 = aura(self_guid, 0xA4, 0, 25, 0, i64::MAX); + let a2 = aura(self_guid, 0xA4, 0, 5, 0, i64::MAX); + let current = vec![a1.clone(), a2.clone()]; + let out = run_speed_packet(current.clone().into_iter(), &a1, self_guid); + let m = match out { + Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(m))) => m, + _ => panic!("expected Some(Outbound::One(SMSG_FORCE_RUN_SPEED_CHANGE))"), + }; + assert_eq!(*m, codec::build_force_run_speed(self_guid, 9.1)); + // A non-move-speed change on self spams nothing (ordinary buffs/debuffs don't trigger this). + let unrelated = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); + assert!(run_speed_packet(current.clone().into_iter(), &unrelated, self_guid).is_none()); + // A move-speed change on a DIFFERENT guid (a peer) never triggers self's packet. + let peer_changed = aura(self_guid + 1, 0xA4, 0, 25, 0, i64::MAX); + assert!(run_speed_packet(current.into_iter(), &peer_changed, self_guid).is_none()); + } + #[test] + fn mounted_snare_apply_and_remove_preserve_the_mount_bonus() { + use lyracore_shared::spell::taxonomy::{A_MOUNTED, SPEED_MOUNTED}; + let rider = 7; + let mounted = aura(rider, A_MOUNTED, 1147, 0, 0, i64::MAX); + let bonus = aura( + rider, + A_MOD_SPEED, + i32::from(SPEED_MOUNTED), + 60, + 0, + i64::MAX, + ); + let snare = aura(rider, A_MOD_SPEED, i32::from(SPEED_MOVE), -30, 0, i64::MAX); + for (current, expected) in [ + (vec![mounted.clone(), bonus.clone(), snare.clone()], 9.1), + (vec![mounted.clone(), bonus.clone()], 11.2), + (vec![bonus.clone(), snare.clone()], 4.9), + ] { + let Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(packet))) = + run_speed_packet(current.into_iter(), &snare, rider) + else { + panic!("speed packet"); + }; + assert_eq!(*packet, codec::build_force_run_speed(rider, expected)); + } + } + + #[test] + fn stacked_snare_packet_uses_the_ten_percent_floor() { + let mut snare = aura(7, A_MOD_SPEED, i32::from(SPEED_MOVE), -30, 0, i64::MAX); + snare.stacks = 4; + let Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(packet))) = + run_speed_packet([snare.clone()].into_iter(), &snare, 7) + else { + panic!("speed packet"); + }; + assert_eq!(*packet, codec::build_force_run_speed(7, 0.7)); + } +} diff --git a/gateway/src/codec/corpse.rs b/gateway/src/codec/corpse.rs index 4b83ad93c..51e4c228c 100644 --- a/gateway/src/codec/corpse.rs +++ b/gateway/src/codec/corpse.rs @@ -105,3 +105,5 @@ pub fn build_resurrect_request(caster_guid: u64, caster_name: String) -> SMSG_RE player: true, } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/corpse/subscriptions.rs b/gateway/src/codec/corpse/subscriptions.rs new file mode 100644 index 000000000..253ab9aec --- /dev/null +++ b/gateway/src/codec/corpse/subscriptions.rs @@ -0,0 +1,168 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::views::corpse_view; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::{Outbound, SessionTx}; +use std::sync::Arc; +use std::time::Duration; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +pub(crate) fn relay_corpse_create(tx: &SessionTx, self_guid: u64, row: &Corpse) { + for o in corpse_create_outbound(self_guid, row) { + let _ = tx.send(o); + } +} + +/// The corpse CREATE body as outbound packets — shared by the per-player relay, the login +/// resident sweep, and the shared-dispatch twin. The owner's reclaim-delay +/// packet rides inside, keyed on the VIEWER's guid. +pub(crate) fn corpse_create_outbound(self_guid: u64, row: &Corpse) -> Vec { + let m = codec::build_corpse_create_object(&corpse_view(row.clone())); + let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(m), + ))]; + if row.owner_guid == self_guid { + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_CORPSE_RECLAIM_DELAY( + wow_world_messages::vanilla::SMSG_CORPSE_RECLAIM_DELAY { + delay: Duration::from_micros(row.reclaim_delay_micros as u64), + }, + ), + )); + } + out +} + +/// The shared-dispatch twin of the per-player `on_corpse_update` relay (corpses): the body→bones +/// re-CREATE. The instance gate already ran in `world_view::corpse_changed`. +pub(crate) fn relay_corpse_update(row: &Corpse) -> Vec { + let m = codec::build_corpse_create_object(&corpse_view(row.clone())); + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(m), + ))] +} + +/// Resurrect prompt: the packet body both legs run. Audience: the +/// offer's target, resolved by the caller. +pub(crate) fn resurrect_request_outbound(row: &ResurrectRequest) -> Vec { + let m = codec::build_resurrect_request(row.caster_guid, row.caster_name.clone()); + vec![Outbound::One(ServerOpcodeMessage::SMSG_RESURRECT_REQUEST( + Box::new(m), + ))] +} + +/// Self-Resurrection Option: the owner's `PLAYER_SELF_RES_SPELL` write. `spell_id` 0 clears it. +/// Audience: the option's Character, resolved by the caller. +pub(crate) fn self_res_option_outbound(character_guid: u64, spell_id: u32) -> Vec { + let m = codec::build_self_res_spell_values(character_guid, spell_id); + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(m), + ))] +} + +/// A player corpse appeared → CREATE for every same-instance viewer on the row's shard, with the +/// owner's reclaim-delay packet riding inside (`corpse_create_outbound`, the same body the +/// per-player relay and the login resident sweep use). +pub(crate) fn corpse_appeared(view: &WorldView, shard: ShardId, row: &Corpse) { + let row = Arc::new(row.clone()); + for viewer in viewers_on_shard(view, shard) { + if !crate::codec::entity::subscriptions::instance_relay_gate( + row.instance_id, + Some(viewer.instance_id), + ) { + continue; + } + let row = row.clone(); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |_| { + crate::codec::corpse::subscriptions::corpse_create_outbound(self_guid, &row) + }); + } +} + +/// A corpse's state changed (body → bones decay) → re-emit the CREATE so the client re-renders it, +/// same instance gate as the insert leg (the re-emit must not leak across instances either). +pub(crate) fn corpse_changed(view: &WorldView, shard: ShardId, row: &Corpse) { + let row = Arc::new(row.clone()); + for viewer in viewers_on_shard(view, shard) { + if !crate::codec::entity::subscriptions::instance_relay_gate( + row.instance_id, + Some(viewer.instance_id), + ) { + continue; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::corpse::subscriptions::relay_corpse_update(&row) + }); + } +} + +/// A corpse was reclaimed/decayed → DESTROY to every viewer on the shard. Ungated like the +/// per-player delete leg (a DESTROY for an unknown guid is a client no-op). +pub(crate) fn corpse_vanished(view: &WorldView, shard: ShardId, row: &Corpse) { + let guid = row.guid; + for viewer in viewers_on_shard(view, shard) { + enqueue(viewer.clone(), move |_| { + crate::codec::combat::subscriptions::relay_destroy_object(guid) + }); + } +} + +/// A resurrect offer landed → SMSG_RESURRECT_REQUEST to the offer's TARGET and nobody else. +pub(crate) fn resurrect_offered(view: &WorldView, shard: ShardId, row: &ResurrectRequest) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.target_guid)) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.target_guid, + viewer.self_guid, + ) { + return; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::corpse::subscriptions::resurrect_request_outbound(&row) + }); +} + +/// A Self-Resurrection Option changed → the owner's `PLAYER_SELF_RES_SPELL` (0 clears it), and +/// nobody else's. +pub(crate) fn self_res_option_changed( + view: &WorldView, + shard: ShardId, + character_guid: u64, + spell_id: u32, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(character_guid)) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + character_guid, + viewer.self_guid, + ) { + return; + } + enqueue(viewer.clone(), move |_| { + crate::codec::corpse::subscriptions::self_res_option_outbound(character_guid, spell_id) + }); +} + +crate::stdb::relays::declare_relays! { + game_corpse [false] { + insert(Snapshot) => |context, _event, row| corpse_appeared(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| corpse_changed(&context.view, context.shard, row); + delete(Snapshot) => |context, _event, row| corpse_vanished(&context.view, context.shard, row); + } + game_resurrect_request [false] { + insert(Live) => |context, _event, row| resurrect_offered(&context.view, context.shard, row); + } + game_self_resurrect_option [false] { + insert(Snapshot) => |context, _event, row| self_res_option_changed(&context.view, context.shard, row.character_guid, row.spell_id); + update(Snapshot) => |context, _event, _old, row| self_res_option_changed(&context.view, context.shard, row.character_guid, row.spell_id); + delete(Snapshot) => |context, _event, row| self_res_option_changed(&context.view, context.shard, row.character_guid, 0); + } +} diff --git a/gateway/src/codec/duel.rs b/gateway/src/codec/duel.rs index 6ebce450f..7e27d79e5 100644 --- a/gateway/src/codec/duel.rs +++ b/gateway/src/codec/duel.rs @@ -65,3 +65,5 @@ mod tests { .all(|(index, _)| *index != update_mask::idx::OBJECT_TYPE)); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/duel/subscriptions.rs b/gateway/src/codec/duel/subscriptions.rs new file mode 100644 index 000000000..9fbf2c77f --- /dev/null +++ b/gateway/src/codec/duel/subscriptions.rs @@ -0,0 +1,207 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::{CellKey, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use std::sync::Arc; +use std::time::Duration; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Project one Duel lifecycle edge for one recipient. The module inserts one row per participant, +/// so this function deliberately builds one copy of each typed protocol packet per row. +pub(crate) fn duel_event_outbound( + row: &DuelEvent, + template: Option<&codec::GameObjectTemplateView>, +) -> Vec { + use lyracore_shared::duel::{completion_kind, event_kind}; + use wow_world_messages::vanilla::{ + SMSG_DUEL_COMPLETE, SMSG_DUEL_COUNTDOWN, SMSG_DUEL_REQUESTED, + }; + + let raw_values = |guid, arbiter, team| { + let (opcode, body) = codec::build_duel_player_values(guid, arbiter, team); + Outbound::Raw { opcode, body } + }; + + match row.kind { + event_kind::REQUESTED => { + let Some(template) = template else { + log::warn!( + "duel relay: missing duel-flag template {} (event {})", + row.flag_entry, + row.id + ); + return Vec::new(); + }; + let flag = codec::GameObjectView { + guid: row.flag_guid, + template_entry: row.flag_entry, + x: row.flag_x, + y: row.flag_y, + z: row.flag_z, + orientation: row.flag_orientation, + state: 1, + type_id: template.type_id, + display_id: template.display_id, + rotation_0: 0.0, + rotation_1: 0.0, + rotation_2: 0.0, + rotation_3: 0.0, + size: 1.0, + }; + let (rotation_opcode, rotation_body) = codec::build_gameobject_rotation_values(&flag); + vec![ + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_gameobject_create_object(&flag), + ))), + Outbound::Raw { + opcode: rotation_opcode, + body: rotation_body, + }, + raw_values(row.initiator_guid, Some(row.flag_guid), None), + raw_values(row.challenged_guid, Some(row.flag_guid), None), + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_REQUESTED(Box::new( + SMSG_DUEL_REQUESTED { + // The protocol calls this `initiator`; vanilla uses it as the arbiter GO. + initiator: wow_world_messages::Guid::new(row.flag_guid), + target: wow_world_messages::Guid::new(row.initiator_guid), + }, + ))), + ] + } + event_kind::COUNTDOWN => vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COUNTDOWN( + SMSG_DUEL_COUNTDOWN { + // The library labels this Duration as seconds, but writes `as_secs()` directly. + // Vanilla's field is milliseconds and CMaNGOS writes 3000, so 3000 seconds here + // is the typed API spelling that produces the correct u32 wire value. + time: Duration::from_secs(3_000), + }, + ))], + event_kind::ACTIVE => vec![ + raw_values(row.initiator_guid, None, Some(1)), + raw_values(row.challenged_guid, None, Some(2)), + ], + event_kind::OUT_OF_BOUNDS => { + vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_OUTOFBOUNDS)] + } + event_kind::IN_BOUNDS => vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_INBOUNDS)], + event_kind::COMPLETE => { + let mut outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE( + SMSG_DUEL_COMPLETE { + ended_without_interruption: row.completion_kind != completion_kind::INTERRUPTED, + }, + ))]; + outbound.extend(duel_winner_outbound(row)); + outbound.extend([ + raw_values(row.initiator_guid, Some(0), Some(0)), + raw_values(row.challenged_guid, Some(0), Some(0)), + Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(row.flag_guid), + )), + ]); + outbound + } + other => { + log::warn!("duel relay: unknown kind {other} (event {})", row.id); + Vec::new() + } + } +} + +/// The one winner announcement for a completed Duel. Participant relays include it after +/// `SMSG_DUEL_COMPLETE`; the shared view also fans it to nearby non-participants. +pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { + use lyracore_shared::duel::completion_kind; + use wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason; + use wow_world_messages::vanilla::SMSG_DUEL_WINNER; + + if row.winner_guid == 0 + || row.loser_guid == 0 + || row.winner_name.is_empty() + || row.loser_name.is_empty() + { + return Vec::new(); + } + let reason = match row.completion_kind { + completion_kind::WON => DuelWinnerReason::Won, + completion_kind::FLED => DuelWinnerReason::Fled, + _ => return Vec::new(), + }; + // `opponent_name` is the first CString on the wire and carries the WINNER: mangos sends + // this packet from the loser, announcing its opponent as victor. + vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER( + Box::new(SMSG_DUEL_WINNER { + reason, + opponent_name: row.winner_name.clone(), + initiator_name: row.loser_name.clone(), + }), + ))] +} + +/// A Duel lifecycle edge landed for one participant. Private packets go only to that participant; +/// the initiator-addressed completion row also drives the one nearby winner announcement. +pub(crate) fn duel_event_appeared( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &DuelEvent, +) { + if let Some(viewer) = view + .viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) + .filter(|viewer| { + crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) + }) + { + let row = row.clone(); + let coord = coord.clone(); + enqueue(viewer.clone(), move |_| { + let template = (row.kind == lyracore_shared::duel::event_kind::REQUESTED) + .then(|| coord.gameobject_template(row.flag_entry).ok().flatten()) + .flatten(); + crate::codec::duel::subscriptions::duel_event_outbound(&row, template.as_ref()) + }); + } + + // The module writes two participant rows. Use the initiator-addressed copy as the one AOI fan + // trigger, and exclude both private recipients so each client receives one winner packet. + if row.kind != lyracore_shared::duel::event_kind::COMPLETE + || row.recipient_guid != row.initiator_guid + || row.winner_guid == 0 + { + return; + } + let key = CellKey::of_position(row.map_id, row.instance_id, row.flag_x, row.flag_y); + let row = Arc::new(row.clone()); + for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { + if !duel_winner_audience(viewer.self_guid, row.initiator_guid, row.challenged_guid) { + continue; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::duel::subscriptions::duel_winner_outbound(&row) + }); + } +} + +/// The AOI bystander leg of the winner announcement: both participants get their own copy, so the +/// fan-out must skip them (and the unregistered zero guid). +pub(crate) fn duel_winner_audience( + viewer_guid: u64, + initiator_guid: u64, + challenged_guid: u64, +) -> bool { + viewer_guid != 0 && viewer_guid != initiator_guid && viewer_guid != challenged_guid +} + +crate::stdb::relays::declare_relays! { + game_duel_event [false] { + insert(Live) => |context, _event, row| duel_event_appeared(&context.view, &context.coord, context.shard, row); + } +} diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index 7b463761b..5a5f5758d 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -349,13 +349,15 @@ pub fn build_create_object( .set_player_next_level_xp(entity.next_level_xp as i32) .set_player_field_coinage(entity.money as i32) // purse in copper // Free talent points (PLAYER_CHARACTER_POINTS1) so the talent pane shows a non-zero count - // Mirrors the module's `talent::talent_points_available`: 1/level from level 10 + // Shared talent calculation: one point per level from level 10 // (= level - 9), clamped at 0 below 10. NOTE: this does NOT yet subtract points already spent // (the gateway doesn't read game_character_talent here) — it's a display of points EARNED, exact // for a fresh character and approximate after spending. Learning is reducer-gated server-side; // the in-client *clickable* pane additionally needs talent data aligned to the client's // Talent.dbc (real ids + rank-spells) — a separate follow-up, not wired here. - .set_player_character_points1((entity.level as i32 - 9).max(0)) + .set_player_character_points1( + lyracore_shared::talent::points_available(entity.level, 0) as i32, + ) // The five base attributes (UNIT_FIELD_STAT0..4) so the character sheet shows real // numbers (set at login from the cmangos curve; 0 until that data is loaded). Part of the // full CREATE mask — not a partial VALUES update — so no dirty_reset concern. @@ -923,3 +925,5 @@ pub fn build_set_faction_standing_raw( body.extend_from_slice(&(standing as u32).to_le_bytes()); // signed standing, bit-cast (client reads it i32) Some((SMSG_SET_FACTION_STANDING_OPCODE, body)) } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs new file mode 100644 index 000000000..40dfe47b9 --- /dev/null +++ b/gateway/src/codec/entity/subscriptions.rs @@ -0,0 +1,1128 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::combat::subscriptions::aura_sync; +use crate::codec::guild::subscriptions::guild_projection_of; +use crate::codec::movement::subscriptions::append_resident_creature_after_create; +use crate::codec::movement::subscriptions::unix_now_micros; +use crate::codec::taxi::subscriptions::append_resident_taxi_after_create; +use crate::codec::weather::subscriptions::zone_crossed; +use crate::codec::{self, CreateKind}; +use crate::stdb::bindings::*; +use crate::stdb::views::{entity_view, hunter_pet_protocol_view}; +use crate::stdb::world_index::{CellKey, EntityLayer, SessionId, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use anyhow::Result; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use spacetimedb_sdk::Table; +use std::collections::HashSet; +use std::sync::Arc; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Read a peer's equipment and current aura state for AOI entry or stealth reveal. +/// An encoding failure leaves the caller responsible for removing its `created` entry. +pub(crate) fn build_peer_create( + coord: &Coordinator, + world: &WorldView, + shard: crate::stdb::world_index::ShardId, + viewer_guid: u64, + row: &WorldEntity, +) -> Option> { + let inv: Vec = + if row.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 { + coord + .player_items(row.guid) + .unwrap_or_default() + .into_iter() + .filter(|i| i.slot <= 18) + .collect() + } else { + Vec::new() + }; + let view = peer_entity_view( + row, + projected_dynamic_flags(&coord.0.coord().conn.db, viewer_guid, row), + |guid| guild_projection_of(coord, guid), + ); + let auras = world.auras.on_target(shard, row.guid); + let movement = coord + .0 + .coord() + .conn + .db + .game_entity_motion() + .guid() + .find(&row.guid) + .filter(|motion| motion.map_id == row.map_id && motion.instance_id == row.instance_id) + .filter(|motion| { + codec::bytes_to_movement_info(&motion.movement_info).is_ok_and(|info| { + info.timestamp == row.last_move_ms + && info.position.x == row.x + && info.position.y == row.y + && info.position.z == row.z + && info.orientation == row.orientation + }) + }); + match peer_create_outbound( + &view, + &inv, + &auras, + movement + .as_ref() + .map(|motion| motion.movement_info.as_slice()), + ) { + Ok(out) => Some(out), + Err(e) => { + log::warn!("peer create encode failed for guid {}: {e}", row.guid); + None + } + } +} + +/// The view a peer CREATE encodes: the stored row, the viewer-relative dynamic flags, and for a +/// player the Guild Projection `guild_of` reads. +pub(crate) fn peer_entity_view( + row: &WorldEntity, + dynamic_flags: u32, + guild_of: impl FnOnce(u64) -> (u32, u32), +) -> codec::EntityView { + let mut view = entity_view(row.clone(), 0); + view.dynamic_flags = dynamic_flags; + if row.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 { + (view.guild_id, view.guild_rank) = guild_of(row.guid); + } + view +} + +/// A peer's current auras follow its CREATE. Their rows may have arrived before the viewer +/// could see it, so another aura callback cannot be relied on to supply them. +pub(crate) fn peer_create_outbound( + view: &codec::EntityView, + inventory: &[codec::ItemInstanceView], + auras: &[Aura], + movement: Option<&[u8]>, +) -> Result> { + let create = codec::build_create_object(view, CreateKind::Peer, inventory, &[])?; + let created = match movement { + Some(movement) => Outbound::Raw { + opcode: 0x00a9, + body: codec::create_with_movement(&create, movement)?, + }, + None => Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(create))), + }; + let mut out = vec![created]; + if !auras.is_empty() { + out.push(aura_sync(auras.iter().cloned(), view.guid)); + } + Ok(out) +} + +struct LootTagProjectionRows<'a> { + tap: Option<&'a CreatureQuestTap>, + tag_group: Option<&'a CreatureLootTagGroup>, + tag_members: &'a [CreatureQuestTapMember], + current_members: &'a [GroupMember], + corpse_eligible: &'a [CorpseLootEligible], +} + +/// Render the stored entity flags for one viewer without changing the durable row. +fn viewer_relative_dynamic_flags( + viewer_guid: u64, + entity: &WorldEntity, + rows: LootTagProjectionRows<'_>, +) -> u32 { + use lyracore_shared::constants::unit_dynamic_flags::{LOOTABLE, TAPPED_BY_PLAYER}; + + if entity.dead { + return if rows + .corpse_eligible + .iter() + .any(|row| row.corpse_guid == entity.guid && row.eligible_guid == viewer_guid) + { + entity.dynamic_flags + } else { + entity.dynamic_flags & !LOOTABLE + }; + } + + let Some(tap) = rows.tap.filter(|tap| tap.creature_guid == entity.guid) else { + return entity.dynamic_flags; + }; + let entitled = match rows + .tag_group + .filter(|group| group.creature_guid == entity.guid) + { + Some(group) => { + rows.tag_members.iter().any(|member| { + member.creature_guid == entity.guid && member.character_guid == viewer_guid + }) && rows.current_members.iter().any(|member| { + member.character_guid == viewer_guid && member.group_id == group.group_id + }) + } + None => viewer_guid == tap.character_guid, + }; + if entitled { + entity.dynamic_flags | TAPPED_BY_PLAYER + } else { + entity.dynamic_flags & !TAPPED_BY_PLAYER + } +} + +pub(crate) fn projected_dynamic_flags( + db: &RemoteTables, + viewer_guid: u64, + entity: &WorldEntity, +) -> u32 { + let tap = db + .game_creature_quest_tap() + .creature_guid() + .find(&entity.guid); + let tag_group = db + .game_creature_loot_tag_group() + .creature_guid() + .find(&entity.guid); + let tag_members: Vec<_> = db + .game_creature_quest_tap_member() + .iter() + .filter(|member| member.creature_guid == entity.guid) + .collect(); + let current_members: Vec<_> = db + .game_group_member() + .iter() + .filter(|member| member.character_guid == viewer_guid) + .collect(); + let corpse_eligible: Vec<_> = db + .game_corpse_loot_eligible() + .iter() + .filter(|eligible| eligible.corpse_guid == entity.guid) + .collect(); + viewer_relative_dynamic_flags( + viewer_guid, + entity, + LootTagProjectionRows { + tap: tap.as_ref(), + tag_group: tag_group.as_ref(), + tag_members: &tag_members, + current_members: ¤t_members, + corpse_eligible: &corpse_eligible, + }, + ) +} + +/// May an instance-tagged CORPSE/GAMEOBJECT row be CREATE-relayed to this +/// viewer? Instance equality against a KNOWN viewer instance — `None` (viewer entity not resident, +/// see `viewer_instance`) relays nothing: suppress-then-sweep is strictly safer than the old +/// default-to-0 (which leaked instance-0 rows to a viewer whose real instance wasn't known yet). +/// Kept map-agnostic deliberately: instance-0 rows keep today's relay behavior byte-identical for +/// every resident open-world viewer, while a per-instance copy (or an in-instance corpse) reaches +/// ONLY viewers inside that instance. DELETE relays stay ungated (the `on_melee_delete` precedent — +/// SMSG_DESTROY_OBJECT for a never-created guid is a client no-op, and gating deletes risks +/// leaking a stale object on an instance transition). Pure — unit-tested below. +pub(crate) fn instance_relay_gate(row_instance_id: u64, viewer_instance_id: Option) -> bool { + viewer_instance_id == Some(row_instance_id) +} + +pub(crate) fn peer_create_gate( + row_guid: u64, + self_guid: u64, + row_instance_id: u64, + viewer_instance_id: u64, + row_npc_flags: u32, + viewer_is_ghost: bool, + row_is_stealthed: bool, +) -> bool { + const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 + if row_guid == self_guid { + return false; + } + if row_instance_id != viewer_instance_id { + return false; + } + if row_npc_flags & SPIRITHEALER_NPC_FLAG != 0 && !viewer_is_ghost { + return false; + } + if row_is_stealthed { + return false; + } + true +} + +#[must_use] +pub(crate) fn offer_peer_create_for( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + viewer: &Viewer, + row: &WorldEntity, +) -> Vec { + const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 + if row.guid == viewer.self_guid || row.instance_id != viewer.instance_id { + return Vec::new(); + } + let viewer_is_ghost = viewer.gates.is_ghost(); + // A spirit healer viewed by a living player is refused regardless of stealth. + if row.npc_flags & SPIRITHEALER_NPC_FLAG != 0 && !viewer_is_ghost { + return Vec::new(); + } + let row_is_stealthed = view.auras.is_stealthed(shard, row.guid); + let guard = coord.0.coord(); + let db = &guard.conn.db; + if !peer_create_gate( + row.guid, + viewer.self_guid, + row.instance_id, + viewer.instance_id, + row.npc_flags, + viewer_is_ghost, + row_is_stealthed, + ) { + return Vec::new(); + } + if !viewer.created.lock().unwrap().insert(row.guid) { + return Vec::new(); + } + let Some(mut out) = build_peer_create(coord, view, shard, viewer.self_guid, row) else { + viewer.created.lock().unwrap().remove(&row.guid); + return Vec::new(); + }; + if let Some(equipment) = db.game_encounter_equip().creature_guid().find(&row.guid) { + append_encounter_equip_after_create(&mut out, viewer, &equipment); + } + // Pet action bar: this viewer's OWN summoned pet just appeared — bind it client-side + // (UNIT_FIELD_SUMMON + the pet action bar). + if row.owner_guid == viewer.self_guid { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_owner_summon_values(viewer.self_guid, row.guid)), + ))); + if let Some(pet) = db + .game_hunter_pet_protocol() + .iter() + .find(|pet| pet.live_pet_guid == row.guid && pet.owner_guid == viewer.self_guid) + { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_hunter_pet_values(&hunter_pet_protocol_view( + pet, + ))), + ))); + } + let spells: Vec = db + .game_creature_cast() + .iter() + .filter(|c| c.creature_entry == row.entry) + .map(|c| c.spell_id) + .collect(); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_PET_SPELLS( + Box::new(codec::build_pet_spells(row.guid, &spells)), + ))); + } + let resident_creature_spline = db.game_creature_spline().guid().find(&row.guid); + append_resident_creature_after_create( + &mut out, + &viewer.created, + viewer.instance_id, + resident_creature_spline.as_ref(), + unix_now_micros(), + ); + if let Some(spline) = db + .game_taxi_passenger_spline() + .character_guid() + .find(&row.guid) + { + append_resident_taxi_after_create(&mut out, &viewer.created, viewer.self_guid, &spline); + } + out +} + +/// Relay one durable creature virtual-item projection to a viewer that already holds the creature. +/// A delete clears all three display slots. The raw builder is the crash-safe sparse VALUES path. +pub(crate) fn encounter_equip_outbound( + viewer: &Viewer, + row: &EncounterEquip, + cleared: bool, +) -> Vec { + if !viewer.created.lock().unwrap().contains(&row.creature_guid) { + return Vec::new(); + } + let (main_hand, off_hand, ranged) = if cleared { + (0, 0, 0) + } else { + (row.main_hand, row.off_hand, row.ranged) + }; + let (opcode, body) = + codec::build_virtual_item_values(row.creature_guid, main_hand, off_hand, ranged); + vec![Outbound::Raw { opcode, body }] +} + +pub(crate) fn append_encounter_equip_after_create( + out: &mut Vec, + viewer: &Viewer, + row: &EncounterEquip, +) { + out.extend(encounter_equip_outbound(viewer, row, false)); +} + +#[must_use] +pub(crate) fn relay_entity_update( + coord: &Coordinator, + view: &WorldView, + shard: crate::stdb::world_index::ShardId, + viewer: &Viewer, + old: &WorldEntity, + new: &WorldEntity, +) -> Vec { + const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; + const GHOST_PLAYER_FLAG: u32 = lyracore_shared::constants::player_flags::GHOST; + if new.guid != viewer.self_guid { + let shown = viewer.created.lock().unwrap().contains(&new.guid); + if is_update_reentry(new.guid, viewer.self_guid, shown) { + return offer_peer_create_for(coord, view, shard, viewer, new); + } + } + let dynamic_flags = { + let guard = coord.0.coord(); + projected_dynamic_flags(&guard.conn.db, viewer.self_guid, new) + }; + let mut out: Vec = + entity_update_to_outbound_with_dynamic_flags(old, new, dynamic_flags) + .into_iter() + .map(Outbound::One) + .collect(); + if new.guid == viewer.self_guid { + if let Some(is_ghost) = + ghost_transition(old.player_flags, new.player_flags, GHOST_PLAYER_FLAG) + { + viewer + .gates + .is_ghost + .store(is_ghost, std::sync::atomic::Ordering::Relaxed); + // Every spirit healer currently IN VIEW (the index answers that; before the + // shared-connection model this scanned the AOI-scoped connection cache, which was the + // same set). Idempotent through + // `created`, exactly like the stealth Reveal/Hide arms. + let guard = coord.0.coord(); + let healers: Vec = guard + .conn + .db + .game_world_entity() + .iter() + .filter(|e| { + e.npc_flags & SPIRITHEALER_NPC_FLAG != 0 && e.instance_id == new.instance_id + }) + .collect(); + drop(guard); + for h in healers { + if is_ghost { + out.extend(offer_peer_create_for(coord, view, shard, viewer, &h)); + } else if viewer.created.lock().unwrap().remove(&h.guid) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(h.guid), + ))); + } + } + } + } + out +} + +/// Re-send the projected flags for visible live Loot Tags after the shard membership mirror +/// changes. Membership does not rewrite a creature's `WorldEntity`, so its normal VALUES relay has +/// nothing to carry. +pub(crate) fn loot_tag_flags_after_membership_change( + coord: &Coordinator, + viewer: &Viewer, +) -> Vec { + use lyracore_shared::constants::unit_dynamic_flags::TAPPED; + + let shown = viewer.created.lock().unwrap().clone(); + let guard = coord.0.coord(); + shown + .iter() + .filter_map(|guid| guard.conn.db.game_world_entity().guid().find(guid)) + .filter(|entity| !entity.dead && entity.dynamic_flags & TAPPED != 0) + .map(|entity| { + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_dynamic_flags_values( + entity.guid, + projected_dynamic_flags(&guard.conn.db, viewer.self_guid, &entity), + ), + ))) + }) + .collect() +} + +#[must_use] +pub(crate) fn relay_peer_destroy(viewer: &Viewer, guid: u64, owner_guid: u64) -> Vec { + if guid == viewer.self_guid { + return Vec::new(); + } + if !viewer.created.lock().unwrap().remove(&guid) { + return Vec::new(); + } + let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(guid), + ))]; + // Pet action bar: this viewer's own pet despawned — clear UNIT_FIELD_SUMMON and send the empty + // SMSG_PET_SPELLS (the vanilla "remove pet bar" form). + if owner_guid == viewer.self_guid { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_owner_summon_values(viewer.self_guid, 0)), + ))); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_PET_SPELLS( + Box::new(codec::build_pet_spells_clear()), + ))); + } + out +} + +/// The `on_update` dispatch decision: does this update need to be treated as a +/// re-entry (offer a fresh CREATE via `offer_peer_create`) instead of a field diff? True exactly when +/// the guid isn't self AND isn't already in the viewer's `created` set — i.e. either this is the first +/// time this viewer has seen the row, or it left scope (evicted by `on_delete`) and is now returning as +/// an UPDATE of the SDK's still-cached row instead of a fresh `on_insert`. Pure over the dedup-set +/// membership so the branch is unit-testable without a live SDK connection. +pub(crate) fn is_update_reentry(guid: u64, self_guid: u64, currently_shown: bool) -> bool { + guid != self_guid && !currently_shown +} + +/// Edge-detect for the spirit-healer reveal: did the GHOST player-flag (mask) cross 0↔1 between `old` +/// and `new`? Returns `Some(true)` on an alive→ghost crossing (REVEAL the in-scope spirit healers), +/// `Some(false)` on ghost→alive (DESTROY them), `None` when the bit didn't change (the common heartbeat / +/// any other field update — no reveal work). Pure so the on_update closure stays a thin relay and this +/// stays unit-testable (the closure itself needs a live EventContext to enumerate healers). +pub(crate) fn ghost_transition(old_flags: u32, new_flags: u32, ghost_mask: u32) -> Option { + let was = old_flags & ghost_mask != 0; + let is = new_flags & ghost_mask != 0; + if was == is { + None + } else { + Some(is) + } +} + +/// Diff an observed `game_world_entity` row (`old` → `new`) into the partial-VALUES `SMSG_UPDATE_OBJECT` +/// packets that replicate the change to this player's client. Pure (no I/O) so it is unit-testable — the +/// `on_update` relay callback just sends whatever this returns, in order. The level-up branch is +/// MUTUALLY EXCLUSIVE with the individual field diffs: a ding pushes ONE combined VALUES block (level + +/// vitals + xp together) instead of the separate health/xp packets, so the panel + bars flip in lockstep. +/// Player-only fields (xp / coinage / ghost / power) are gated on the PLAYER bit; health and dynamic-flags +/// apply to any unit (creatures' health bars + loot sparkle). Order is preserved from the original relay. +#[cfg(test)] +pub(crate) fn entity_update_to_outbound( + old: &WorldEntity, + new: &WorldEntity, +) -> Vec { + entity_update_to_outbound_with_dynamic_flags(old, new, new.dynamic_flags) +} + +pub(crate) fn entity_update_to_outbound_with_dynamic_flags( + old: &WorldEntity, + new: &WorldEntity, + dynamic_flags: u32, +) -> Vec { + let mut out = Vec::new(); + let is_player = new.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0; + if is_player && old.level != new.level { + // Level-up ding: level + maxhealth + health + xp in ONE VALUES block (the panel + health/XP bars + // flip together). SMSG_LEVELUP_INFO (relayed separately) is the cosmetic popup only. + let m = codec::build_levelup_values( + new.guid, + new.level, + new.health, + new.max_health, + new.xp, + new.next_level_xp, + (new.unit_bytes_0 >> 24) as u8, + new.max_power, + ); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } else { + if old.health != new.health { + let m = codec::build_health_values(new.guid, new.health); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Dynamic-flags VALUES relay: the killing blow sets UNIT_DYNFLAG_LOOTABLE (0x1) on a corpse that + // rolled money; the loot reducer clears it. NOTE: UNIT_DYNFLAG_DEAD (0x20) is NEVER set + // (it is feign-death in vanilla — see combat + lyracore-shared constants). + if old.dynamic_flags != new.dynamic_flags { + let m = codec::build_dynamic_flags_values(new.guid, dynamic_flags); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Target relay (UNIT_FIELD_TARGET): observers see who this unit selected — the target ring / + // target-of-target / assist. Any unit (a creature's aggro target relays too), not player-gated. + if old.target_guid != new.target_guid { + let m = codec::build_target_values(new.guid, new.target_guid); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Max-vitals relay: a non-level-up max_health/max_power change (+Stamina/+Intellect gear or a + // Fortitude/Mark aura) moves the bar DENOMINATOR live for observers. Any unit. + if old.max_health != new.max_health || old.max_power != new.max_power { + let power_b = (new.unit_bytes_0 >> 24) as u8; + let m = + codec::build_max_vitals_values(new.guid, new.max_health, power_b, new.max_power); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Taxi presentation changes mount display + the TAXI_FLIGHT unit flag in one mask (activation + // and landing set both atomically), so the client never sees an intermediate + // mounted-but-controllable or unmounted-but-in-flight state — detected by the flight bit itself + // toggling, not just "unit_flags changed", so an unrelated flag change alongside a land mount + // doesn't get mistaken for a taxi transition. A LAND mount/dismount changes `mount_display_id` + // ALONE (the flight bit is untouched) and relays through the standalone single-field builder + // instead, never alongside the coupled one for the same change. + let taxi_flight_toggled = (old.unit_flags ^ new.unit_flags) + & lyracore_shared::constants::unit_flags::TAXI_FLIGHT + != 0; + if old.mount_display_id != new.mount_display_id && taxi_flight_toggled { + let m = codec::build_taxi_presentation_values( + new.guid, + new.mount_display_id, + new.unit_flags, + ); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } else { + if old.mount_display_id != new.mount_display_id { + let m = codec::build_mount_display_values(new.guid, new.mount_display_id); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + if old.unit_flags != new.unit_flags { + let m = codec::build_unit_flags_values(new.guid, new.unit_flags); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + } + // Sheath relay: UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. + // Any unit, not player-gated — a creature drawing on engage is the same wire field. Without + // this arm the server knows the state and no observer ever hears about it. + if old.unit_bytes_2 != new.unit_bytes_2 { + let m = codec::build_sheath_values(new.guid, new.unit_bytes_2); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Live XP-bar update: players only (creatures never change xp; player-only fields). + let xp_changed = old.xp != new.xp || old.next_level_xp != new.next_level_xp; + if xp_changed && is_player { + let m = codec::build_player_xp_values(new.guid, new.xp, new.next_level_xp); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Live purse update: PLAYER_FIELD_COINAGE on money change (looting). Players only. + if old.money != new.money && is_player { + let m = codec::build_coinage_values(new.guid, new.money); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Live bank bag slot count: PLAYER_BYTES_2 byte 2 on a purchase, so the bank window grows a + // slot without a relog. Scoped to byte 2 only — byte 3 (rest state) already relays through + // its own dedicated `game_rest_state_event` path and must not double-fire here. + const BANK_SLOT_BYTE: u32 = 0x00FF_0000; + if is_player + && (old.player_bytes_2 & BANK_SLOT_BYTE) != (new.player_bytes_2 & BANK_SLOT_BYTE) + { + let m = codec::build_bank_bag_slots_values(new.guid, new.player_bytes_2); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // PLAYER_FLAGS and the UNIT_FIELD_BYTES_1 vis bit: the ghost transition on Release Spirit + // and reclaim, and the AFK and DND bits of an Away Status. Players only. + if is_player + && (old.player_flags != new.player_flags || old.unit_bytes_1 != new.unit_bytes_1) + { + let m = codec::build_player_flags_values(new.guid, new.player_flags, new.unit_bytes_1); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // Live power update: rage/energy/mana changing in combat. The power-type byte of unit_bytes_0 picks + // the right UNIT_FIELD_POWER_N. Players only — else the rage bar stays at 0 and rage spells grey out. + if is_player && old.power != new.power { + let power_b = (new.unit_bytes_0 >> 24) as u8; + let m = codec::build_power_values(new.guid, power_b, new.power); + out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); + } + // GM playtest `.speed`: a `run_speed_mult_bp` change relays + // SMSG_FORCE_RUN_SPEED_CHANGE — player movement is client-authoritative, so the server-side + // field alone never speeds the client up. Mirrors the existing `A_MOD_SPEED(MOVE)` aura relay + // (`run_speed_packet`, same `BASE_RUN_SPEED` constant) but keyed off THIS field, any unit (not + // player-gated — a GM could in principle god/speed-tag a creature down the line, and the relay + // is inert for anyone not watching this guid). + if old.run_speed_mult_bp != new.run_speed_mult_bp { + const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s — same constant as `run_speed_packet` + let speed = BASE_RUN_SPEED * (new.run_speed_mult_bp as f32 / 10_000.0); + out.push(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( + codec::build_force_run_speed(new.guid, speed), + ))); + } + } + out +} + +/// A row's cell, taken from the grid columns the WRITER stamped — not recomputed from `x`/`y`. +/// +/// The subscription predicate this replaces filtered on `grid_x`/`grid_y`, so reading the same +/// columns is what makes the index and the deleted SQL agree by construction rather than by both +/// happening to call `grid_cell` the same way. +pub(crate) fn entity_key(row: &WorldEntity) -> CellKey { + CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y) +} + +/// A row entered the world (or this shard). Index it, then offer a CREATE to everyone who can see +/// its cell. +pub(crate) fn entity_appeared(view: &Arc, shard: ShardId, row: &WorldEntity) { + let key = entity_key(row); + view.spatial.upsert_entity( + EntityLayer::WorldEntity, + row.guid, + key, + shard, + row.owner_guid, + ); + let row = Arc::new(row.clone()); + for session in view.world_entity_recipients(shard, row.guid, key) { + offer_create_job(view, shard, session, &row); + } +} + +/// Create or update for the new cell's viewers, destroy for viewers left behind, +/// and leave all other viewers untouched. +pub(crate) fn entity_changed( + view: &Arc, + shard: ShardId, + old: &WorldEntity, + new: &WorldEntity, +) { + let key = entity_key(new); + let previous = view.spatial.upsert_entity( + EntityLayer::WorldEntity, + new.guid, + key, + shard, + new.owner_guid, + ); + let new_recipients = view.world_entity_recipients(shard, new.guid, key); + if let Some(old_key) = previous { + if old_key != key { + let still: HashSet = new_recipients.iter().copied().collect(); + for viewer in view.cell_audience(shard, Some(old_key), BOX_HALF_SPAN, &[]) { + if !still.contains(&viewer.session) { + destroy_job(view, viewer.session, new.guid, new.owner_guid); + } + } + } + } + let old = Arc::new(old.clone()); + let new = Arc::new(new.clone()); + for session in new_recipients { + update_job(view, shard, session, &old, &new); + } +} + +/// The row is gone from the database. Everyone who could see it gets a DESTROY. +pub(crate) fn entity_vanished(view: &WorldView, shard: ShardId, guid: u64, owner_guid: u64) { + let Some((key, _)) = view + .spatial + .remove_entity(EntityLayer::WorldEntity, guid, shard) + else { + return; + }; + for session in view.world_entity_recipients(shard, guid, key) { + destroy_job(view, session, guid, owner_guid); + } +} + +pub(crate) fn encounter_equip_changed( + view: &WorldView, + shard: ShardId, + row: &EncounterEquip, + cleared: bool, +) { + let Some(coord) = view.shard(shard) else { + return; + }; + let Some(entity) = coord + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&row.creature_guid) + else { + return; + }; + let key = entity_key(&entity); + let row = Arc::new(row.clone()); + for session in view.world_entity_recipients(shard, row.creature_guid, key) { + let Some(viewer) = view.viewer(session) else { + continue; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |viewer| { + crate::codec::entity::subscriptions::encounter_equip_outbound(&viewer, &row, cleared) + }); + } +} + +pub(crate) fn offer_create_job( + view: &Arc, + shard: ShardId, + session: SessionId, + row: &Arc, +) { + let Some(viewer) = view.viewer(session) else { + return; + }; + let Some(coord) = view.shard(shard) else { + return; + }; + let (view, row) = (view.clone(), row.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::entity::subscriptions::offer_peer_create_for( + &coord, &view, shard, &viewer, &row, + ) + }); +} + +pub(crate) fn update_job( + view: &Arc, + shard: ShardId, + session: SessionId, + old: &Arc, + new: &Arc, +) { + let Some(viewer) = view.viewer(session) else { + return; + }; + let Some(coord) = view.shard(shard) else { + return; + }; + let (view, old, new) = (view.clone(), old.clone(), new.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::entity::subscriptions::relay_entity_update( + &coord, &view, shard, &viewer, &old, &new, + ) + }); +} + +pub(crate) fn destroy_job(view: &WorldView, session: SessionId, guid: u64, owner_guid: u64) { + let Some(viewer) = view.viewer(session) else { + return; + }; + enqueue(viewer.clone(), move |viewer| { + crate::codec::entity::subscriptions::relay_peer_destroy(&viewer, guid, owner_guid) + }); +} + +/// A standing row is delivered only to its owner. The wire packet uses the Faction.dbc +/// ReputationListID, never the game's faction id. +pub(crate) fn reputation_appeared(view: &WorldView, shard: ShardId, row: &PlayerReputation) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |_| reputation_outbound(&row)); +} + +pub(crate) fn reputation_outbound(row: &PlayerReputation) -> Vec { + if row.reputation_index < 0 { + return Vec::new(); + } + crate::codec::build_set_faction_standing_raw(row.reputation_index as u32, row.standing) + .map(|(opcode, body)| vec![Outbound::Raw { opcode, body }]) + .unwrap_or_default() +} + +crate::stdb::relays::declare_relays! { + game_player_reputation [false] { + insert(Live) => |context, _event, row| reputation_appeared(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| reputation_appeared(&context.view, context.shard, row); + } + game_world_entity [false] { + insert(Snapshot) => |context, _event, row| entity_appeared(&context.view, context.shard, row); + update(Snapshot) => |context, _event, old, new| { + entity_changed(&context.view, context.shard, old, new); + zone_crossed(&context.view, &context.coord, context.shard, new.guid, old.zone_id, new.zone_id); + }; + delete(Snapshot) => |context, _event, row| entity_vanished(&context.view, context.shard, row.guid, row.owner_guid); + } + game_encounter_equip [false] { + insert(Snapshot) => |context, _event, row| encounter_equip_changed(&context.view, context.shard, row, false); + update(Snapshot) => |context, _event, _old, row| encounter_equip_changed(&context.view, context.shard, row, false); + delete(Snapshot) => |context, _event, row| encounter_equip_changed(&context.view, context.shard, row, true); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + /// A live-entity row with no pending change; tests clone it and mutate one field at a time. + /// `type_mask` carries the PLAYER bit by default (most relayed fields are player-only). + pub(crate) fn player_entity() -> WorldEntity { + WorldEntity { + guid: 1, + owner_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + account_id: 1, + map_id: 0, + x: 0.0, + y: 0.0, + z: 0.0, + orientation: 0.0, + grid_x: 0, + grid_y: 0, + cell: lyracore_shared::spatial::grid_cell_id(0, 0), + last_move_ms: 0, + type_mask: lyracore_shared::constants::type_mask::PLAYER_BIT, + entry: 0, + scale_x: 1.0, + health: 100, + max_health: 100, + power: 100, + max_power: 100, + level: 1, + faction_template: 0, + unit_bytes_0: 0, + display_id: 0, + native_display_id: 0, + unit_flags: 0, + base_attack_time_ms: 2000, + dynamic_flags: 0, + dead: false, + player_bytes: 0, + player_bytes_2: 0, + player_bytes_3: 0, + player_flags: 0, + xp: 0, + next_level_xp: 100, + target_guid: 0, + money: 0, + unit_bytes_1: 0, + unit_bytes_2: 0, + strength: 1, + agility: 1, + stamina: 1, + intellect: 1, + spirit: 1, + npc_flags: 0, + armor: 0, + leg_ends_ms: 0, + wp_target: 0, + movement_flags: 0, + combat_until_ms: 0, + pickpocketed: false, + next_swing_spell: 0, + overpower_until_ms: 0, + revenge_until_ms: 0, + stance: 0, + owner_guid: 0, + skinned: false, + mana_regen_paused_until_ms: 0, + death_expire_micros: 0, + instance_id: 0, + run_speed_mult_bp: 10_000, + godmode: false, + resting: false, + sheet_str_bonus: 0, + sheet_agi_bonus: 0, + sheet_sta_bonus: 0, + sheet_int_bonus: 0, + sheet_spi_bonus: 0, + sheet_ap_base: 0, + sheet_ap_mods: 0, + sheet_dmg_min: 0, + sheet_dmg_max: 0, + sheet_crit_bp: 0, + bank_bag_slots: 0, + mount_display_id: 0, + zone_id: 0, + sheet_ranged_ap: 0, + sheet_ranged_dmg_min: 0, + sheet_ranged_dmg_max: 0, + } + } + + pub(crate) fn creature_entity() -> WorldEntity { + let mut entity = player_entity(); + entity.guid = 99; + entity.type_mask = lyracore_shared::constants::type_mask::CREATURE; + entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::TAPPED; + entity + } + + fn tap(character_guid: u64) -> CreatureQuestTap { + CreatureQuestTap { + creature_guid: 99, + character_guid, + } + } + + fn tag_member(character_guid: u64) -> CreatureQuestTapMember { + CreatureQuestTapMember { + id: character_guid, + creature_guid: 99, + character_guid, + } + } + + fn current_member(character_guid: u64, group_id: u64) -> GroupMember { + GroupMember { + id: character_guid, + group_id, + character_guid, + owner_identity: spacetimedb_sdk::Identity::from_byte_array([0; 32]), + raid_slot: 0, + } + } + + fn project_flags( + viewer_guid: u64, + entity: &WorldEntity, + tap: Option<&CreatureQuestTap>, + tag_group: Option<&CreatureLootTagGroup>, + tag_members: &[CreatureQuestTapMember], + current_members: &[GroupMember], + corpse_eligible: &[CorpseLootEligible], + ) -> u32 { + viewer_relative_dynamic_flags( + viewer_guid, + entity, + LootTagProjectionRows { + tap, + tag_group, + tag_members, + current_members, + corpse_eligible, + }, + ) + } + + #[test] + fn solo_tagger_sees_tapped_by_player() { + let entity = creature_entity(); + let tap = tap(7); + assert_eq!( + project_flags(7, &entity, Some(&tap), None, &[], &[], &[]), + entity.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER + ); + } + + #[test] + fn snapshot_group_member_sees_tapped_by_player() { + let entity = creature_entity(); + let tap = tap(7); + let group = CreatureLootTagGroup { + creature_guid: entity.guid, + group_id: 42, + }; + let members = [tag_member(7), tag_member(8)]; + let current = [current_member(8, 42)]; + assert_eq!( + project_flags( + 8, + &entity, + Some(&tap), + Some(&group), + &members, + ¤t, + &[] + ), + entity.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER + ); + } + + #[test] + fn stranger_sees_only_stored_tapped_flag() { + let entity = creature_entity(); + let tap = tap(7); + assert_eq!( + project_flags(8, &entity, Some(&tap), None, &[], &[], &[]), + entity.dynamic_flags + ); + } + + #[test] + fn later_group_joiner_sees_only_stored_tapped_flag() { + let entity = creature_entity(); + let tap = tap(7); + let group = CreatureLootTagGroup { + creature_guid: entity.guid, + group_id: 42, + }; + let members = [tag_member(7)]; + let current = [current_member(8, 42)]; + assert_eq!( + project_flags( + 8, + &entity, + Some(&tap), + Some(&group), + &members, + ¤t, + &[] + ), + entity.dynamic_flags + ); + } + + #[test] + fn group_leaver_loses_tapped_by_player() { + let entity = creature_entity(); + let tap = tap(7); + let group = CreatureLootTagGroup { + creature_guid: entity.guid, + group_id: 42, + }; + let members = [tag_member(7), tag_member(8)]; + let current = [current_member(8, 99)]; + assert_eq!( + project_flags( + 8, + &entity, + Some(&tap), + Some(&group), + &members, + ¤t, + &[] + ), + entity.dynamic_flags + ); + } + + #[test] + fn eligible_corpse_retains_lootable() { + let mut entity = creature_entity(); + entity.dead = true; + entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; + let eligible = [CorpseLootEligible { + id: 1, + corpse_guid: entity.guid, + eligible_guid: 7, + }]; + assert_eq!( + project_flags(7, &entity, None, None, &[], &[], &eligible), + entity.dynamic_flags + ); + } + + #[test] + fn foreign_viewer_does_not_see_corpse_lootable() { + let mut entity = creature_entity(); + entity.dead = true; + entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; + let eligible = [CorpseLootEligible { + id: 1, + corpse_guid: entity.guid, + eligible_guid: 7, + }]; + assert_eq!( + project_flags(8, &entity, None, None, &[], &[], &eligible), + 0 + ); + } + + #[test] + fn empty_corpse_eligibility_does_not_show_lootable() { + let mut entity = creature_entity(); + entity.dead = true; + entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; + assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0); + } + + #[test] + fn untagged_entity_flags_are_unchanged() { + let mut entity = creature_entity(); + entity.dynamic_flags = 0x42; + assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0x42); + } +} diff --git a/gateway/src/codec/gameobject.rs b/gateway/src/codec/gameobject.rs index 8ffcff850..c7d943b32 100644 --- a/gateway/src/codec/gameobject.rs +++ b/gateway/src/codec/gameobject.rs @@ -392,3 +392,5 @@ mod tests { assert_eq!(found.raw_data, [15, 20, 1581, 0, 0, 0]); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/gameobject/subscriptions.rs b/gateway/src/codec/gameobject/subscriptions.rs new file mode 100644 index 000000000..e15c4a942 --- /dev/null +++ b/gateway/src/codec/gameobject/subscriptions.rs @@ -0,0 +1,113 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::entity::subscriptions::instance_relay_gate; +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::views::go_view; +use crate::stdb::world_index::{CellKey, EntityLayer, SessionId, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use std::collections::HashSet; +use std::sync::Arc; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +pub(crate) fn relay_gameobject_create( + coord: &Coordinator, + viewer: &Viewer, + row: &GameObject, +) -> Vec { + if !instance_relay_gate(row.instance_id, Some(viewer.instance_id)) { + return Vec::new(); + } + let guard = coord.0.coord(); + let Some(tmpl) = guard + .conn + .db + .game_gameobject_template() + .entry() + .find(&row.template_entry) + else { + return Vec::new(); + }; + let view = go_view(row.clone(), &tmpl); + // The CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn + // quaternion (gtker's slot-0-only wall), so the other 3 slots — and the yaw-derive fallback for + // an all-zero stored quaternion — ride a second raw VALUES frame right behind CREATE. The client + // has already materialized the object by the time this second frame arrives (same ordering the + // aura/quest-log raw-VALUES relays rely on elsewhere in this file). + let (rot_opcode, rot_body) = codec::build_gameobject_rotation_values(&view); + vec![ + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_gameobject_create_object(&view), + ))), + Outbound::Raw { + opcode: rot_opcode, + body: rot_body, + }, + ] +} + +pub(crate) fn relay_gameobject_destroy(_viewer: &Viewer, guid: u64) -> Vec { + vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(guid), + ))] +} + +pub(crate) fn gameobject_appeared(view: &WorldView, shard: ShardId, row: &GameObject) { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + let previous = view + .spatial + .upsert_entity(EntityLayer::GameObject, row.guid, key, shard, 0); + let recipients = view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]); + if let Some(old_key) = previous { + if old_key != key { + let still: HashSet = recipients.iter().map(|v| v.session).collect(); + for viewer in view.cell_audience(shard, Some(old_key), BOX_HALF_SPAN, &[]) { + if !still.contains(&viewer.session) { + let guid = row.guid; + enqueue(viewer.clone(), move |viewer| { + crate::codec::gameobject::subscriptions::relay_gameobject_destroy( + &viewer, guid, + ) + }); + } + } + } + } + let Some(coord) = view.shard(shard) else { + return; + }; + let row = Arc::new(row.clone()); + for viewer in recipients { + let (coord, row) = (coord.clone(), row.clone()); + enqueue(viewer.clone(), move |viewer| { + crate::codec::gameobject::subscriptions::relay_gameobject_create(&coord, &viewer, &row) + }); + } +} + +pub(crate) fn gameobject_vanished(view: &WorldView, shard: ShardId, guid: u64) { + let Some((key, _)) = view + .spatial + .remove_entity(EntityLayer::GameObject, guid, shard) + else { + return; + }; + for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { + // DESTROY stays ungated (the `on_melee_delete` precedent): SMSG_DESTROY_OBJECT for a guid + // the client never created is a client no-op, and gating it risks a stale object. + enqueue(viewer.clone(), move |viewer| { + crate::codec::gameobject::subscriptions::relay_gameobject_destroy(&viewer, guid) + }); + } +} + +crate::stdb::relays::declare_relays! { + game_gameobject [false] { + insert(Snapshot) => |context, _event, row| gameobject_appeared(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| gameobject_appeared(&context.view, context.shard, row); + delete(Snapshot) => |context, _event, row| gameobject_vanished(&context.view, context.shard, row.guid); + } +} diff --git a/gateway/src/codec/guild.rs b/gateway/src/codec/guild.rs index d78da4fef..06d06af9d 100644 --- a/gateway/src/codec/guild.rs +++ b/gateway/src/codec/guild.rs @@ -698,3 +698,5 @@ mod tests { assert_eq!(buy.name, "Night Watch"); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/guild/subscriptions.rs b/gateway/src/codec/guild/subscriptions.rs new file mode 100644 index 000000000..118dc1218 --- /dev/null +++ b/gateway/src/codec/guild/subscriptions.rs @@ -0,0 +1,336 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::EntityLayer; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use std::sync::{Arc, OnceLock}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// The Guild Projection of `character_guid`, read from the Realm-core cache. `(0, 0)` when +/// Realm-core cannot answer: the next membership relay corrects it. +pub(crate) fn guild_projection_of(coord: &Coordinator, character_guid: u64) -> (u32, u32) { + coord + .realm_core() + .map_or((0, 0), |realm| realm.guild_projection(character_guid)) +} + +/// Relay the current Guild Projection of `character_guid` to a viewer that holds its entity: the +/// member itself or a viewer whose `created` set has the guid. `projection` is read when the job +/// runs, so a delayed job never writes a value older than the cache. +pub(crate) fn guild_values_outbound( + viewer: &Viewer, + character_guid: u64, + projection: (u32, u32), +) -> Vec { + if viewer.self_guid != character_guid + && !viewer.created.lock().unwrap().contains(&character_guid) + { + return Vec::new(); + } + let (guild_id, rank_id) = projection; + let (opcode, body) = codec::build_guild_values(character_guid, guild_id, rank_id); + vec![Outbound::Raw { opcode, body }] +} + +/// Render one Guild Event row. The row carries its final strings. Kinds 0x40 (INVITE) and 0x41 +/// (DECLINE) are membership offers with their own wire shape; every other kind not built from a +/// shared snapshot (see `GuildEventSnapshot`) rides the generic SMSG_GUILD_EVENT builder. +pub(crate) fn guild_event_outbound(row: &GuildEvent) -> Vec { + match row.kind { + lyracore_shared::guild::event_kind::INVITE => { + let actor_name = row.strings.first().cloned().unwrap_or_default(); + let guild_name = row.strings.get(1).cloned().unwrap_or_default(); + vec![Outbound::One(ServerOpcodeMessage::SMSG_GUILD_INVITE( + Box::new(codec::build_guild_invite(actor_name, guild_name)), + ))] + } + lyracore_shared::guild::event_kind::DECLINE => { + let actor_name = row.strings.first().cloned().unwrap_or_default(); + let (opcode, body) = codec::build_guild_decline_raw(&actor_name); + vec![Outbound::Raw { opcode, body }] + } + _ => { + let (opcode, body) = + codec::build_guild_event_raw(row.kind, &row.strings, row.subject_guid); + vec![Outbound::Raw { opcode, body }] + } + } +} + +/// Render one petition Guild Event (`0x70..=0x7F`). The Charter is in `other_guid` and the +/// signer or decliner in `subject_guid`. `petition` is the Petition of that Charter, read when the +/// job runs; only PETITION_OFFERED and PETITION_CHANGED need it, and a Petition that is gone by +/// then renders nothing. +pub(crate) fn petition_event_outbound( + row: &GuildEvent, + petition: Option<&codec::PetitionView>, +) -> Vec { + use lyracore_shared::guild::event_kind; + use wow_world_messages::vanilla::{ + GuildCommand, GuildCommandResult, PetitionResult, MSG_PETITION_DECLINE, + }; + let sign_results = |result| { + vec![Outbound::One( + ServerOpcodeMessage::SMSG_PETITION_SIGN_RESULTS(Box::new( + codec::build_petition_sign_results(row.other_guid, row.subject_guid, result), + )), + )] + }; + match row.kind { + event_kind::PETITION_OFFERED => petition + .map(|petition| { + Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES( + Box::new(codec::build_petition_show_signatures(petition)), + )) + }) + .into_iter() + .collect(), + event_kind::PETITION_SIGNED => sign_results(PetitionResult::Ok), + event_kind::PETITION_ALREADY_SIGNED => sign_results(PetitionResult::AlreadySigned), + event_kind::PETITION_DECLINED => vec![Outbound::One( + ServerOpcodeMessage::MSG_PETITION_DECLINE(MSG_PETITION_DECLINE { + petition: wow_world_messages::Guid::new(row.subject_guid), + }), + )], + // Result 0 is success; gtker names the zero value PLAYER_NO_MORE_IN_GUILD. + event_kind::FOUNDER => vec![Outbound::One( + ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(Box::new( + codec::build_guild_command_result( + GuildCommand::Founder, + row.strings.first().cloned().unwrap_or_default(), + GuildCommandResult::PlayerNoMoreInGuild, + ), + )), + )], + event_kind::PETITION_CHANGED => petition + .map(|petition| { + Outbound::One(ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(Box::new( + codec::build_petition_query_response(petition), + ))) + }) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn guild_membership_read(realm: &Coordinator) -> GuildMembershipRead { + let realm = realm.clone(); + Arc::new(move |character_guid| { + realm + .0 + .coord() + .conn + .db + .game_guild_member() + .character_guid() + .find(&character_guid) + .map(|member| (member.guild_id, member.rank_id)) + }) +} + +pub(crate) fn guild_roster_snapshot_read(realm: &Coordinator) -> GuildRosterSnapshotRead { + let realm = realm.clone(); + Arc::new(move |guild_id, kind| crate::world::GuildEventSnapshot::build(&realm, guild_id, kind)) +} + +pub(crate) fn petition_read(realm: &Coordinator) -> PetitionRead { + let realm = realm.clone(); + Arc::new(move |charter_item_guid| realm.guild_petition_of_charter(charter_item_guid)) +} + +/// A membership row changed: re-send the member's Guild Projection to the member and to every +/// viewer that holds its entity. The member is always addressed, as `cell_audience` keeps its owner +/// leg, so an entity the cell index has not seen yet cannot hide a removal from the member's own +/// client. The job reads membership when it runs, so after a delete it sends 0 and 0. +pub(crate) fn guild_membership_changed( + view: &WorldView, + membership: &GuildMembershipRead, + character_guid: u64, +) { + let mut sessions = view + .spatial + .shard_of(EntityLayer::WorldEntity, character_guid) + .and_then(|shard| { + view.spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, character_guid, shard) + .map(|key| view.world_entity_recipients(shard, character_guid, key)) + }) + .unwrap_or_default(); + if let Some(owner) = view.session_of_owner(character_guid) { + if !sessions.contains(&owner) { + sessions.push(owner); + } + } + for session in sessions { + let Some(viewer) = view.viewer(session) else { + continue; + }; + let membership = membership.clone(); + enqueue(viewer, move |viewer| { + let projection = membership(character_guid).unwrap_or((0, 0)); + crate::codec::guild::subscriptions::guild_values_outbound( + &viewer, + character_guid, + projection, + ) + }); + } +} + +/// A Guild Event landed. An addressed row reaches its recipient; a broadcast row reaches every +/// online member on this Gateway. A member signing on does not hear its own SIGNED_ON: mangos +/// broadcasts it before the Character is in the world (`cm:CharacterHandler.cpp:787`). +/// +/// `TABARD_CHANGED`, `ROSTER_TO_ACTOR` and `ROSTER_REFRESH` also carry a query response, a roster, +/// or both; the first recipient's job reads the Guild (and, for the two roster kinds, its roster) +/// once through `snapshot_read` and every other recipient's job reuses that one snapshot, so a +/// broadcast to a large Guild costs one Realm-core read, not one per online member. +pub(crate) fn guild_event_appeared( + view: &WorldView, + membership: &GuildMembershipRead, + snapshot_read: &GuildRosterSnapshotRead, + row: &GuildEvent, +) { + let audience: Vec> = if row.recipient_guid != 0 { + view.session_of_owner(row.recipient_guid) + .and_then(|session| view.viewer(session)) + .filter(|viewer| { + crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) + }) + .into_iter() + .collect() + } else { + online_audience(view, membership, row.guild_id) + }; + let row = Arc::new(row.clone()); + let shared_snapshot: Arc> = + Arc::new(OnceLock::new()); + for viewer in audience { + if row.recipient_guid == 0 + && row.kind == lyracore_shared::guild::event_kind::SIGNED_ON + && viewer.self_guid == row.subject_guid + { + continue; + } + let (row, membership, snapshot_read, shared_snapshot) = ( + row.clone(), + membership.clone(), + snapshot_read.clone(), + shared_snapshot.clone(), + ); + enqueue(viewer, move |viewer| { + use lyracore_shared::guild::event_kind; + match row.kind { + event_kind::TABARD_CHANGED => { + let snapshot = + shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); + let mut out = crate::codec::guild::subscriptions::guild_event_outbound(&row); + out.extend(snapshot.query_response().map(Outbound::One)); + out + } + event_kind::ROSTER_TO_ACTOR => { + let Some((_, rank_id)) = + membership(viewer.self_guid).filter(|(id, _)| *id == row.guild_id) + else { + return Vec::new(); + }; + let snapshot = + shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); + snapshot + .roster_for_rank(rank_id) + .into_iter() + .map(Outbound::One) + .collect() + } + event_kind::ROSTER_REFRESH => { + let Some((_, rank_id)) = + membership(viewer.self_guid).filter(|(id, _)| *id == row.guild_id) + else { + return Vec::new(); + }; + let snapshot = + shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); + let mut out: Vec = snapshot + .query_response() + .map(Outbound::One) + .into_iter() + .collect(); + out.extend(snapshot.roster_for_rank(rank_id).map(Outbound::One)); + out + } + _ => crate::codec::guild::subscriptions::guild_event_outbound(&row), + } + }); + } +} + +/// A petition Guild Event landed: it reaches its one addressed recipient. The two kinds that show +/// a Petition read it when the job runs, once per event. +pub(crate) fn petition_event_appeared( + view: &WorldView, + petitions: &PetitionRead, + row: &GuildEvent, +) { + let Some(viewer) = view + .session_of_owner(row.recipient_guid) + .and_then(|session| view.viewer(session)) + .filter(|viewer| { + crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) + }) + else { + return; + }; + let (row, petitions) = (row.clone(), petitions.clone()); + enqueue(viewer, move |_viewer| { + use lyracore_shared::guild::event_kind; + let petition = match row.kind { + event_kind::PETITION_OFFERED | event_kind::PETITION_CHANGED => { + petitions(row.other_guid) + } + _ => None, + }; + crate::codec::guild::subscriptions::petition_event_outbound(&row, petition.as_ref()) + }); +} + +/// A Petition opened: its owner's Guild Charter shows the Petition id in ITEM_FIELD_ENCHANTMENT, +/// where the client reads it back for its petition queries (`cm:PetitionsHandler.cpp:141`). +pub(crate) fn charter_petition_opened(view: &WorldView, row: &GuildPetition) { + let Some(viewer) = view + .session_of_owner(row.owner_guid) + .and_then(|session| view.viewer(session)) + .filter(|viewer| viewer.self_guid == row.owner_guid) + else { + return; + }; + let (charter_item_guid, petition_id) = (row.charter_item_guid, row.petition_id); + enqueue(viewer, move |_viewer| { + let (opcode, body) = + crate::codec::build_charter_petition_values(charter_item_guid, petition_id); + vec![Outbound::Raw { opcode, body }] + }); +} + +crate::stdb::relays::declare_relays! { + game_guild_event [true] { + insert(Live) => |context, _event, row| { if lyracore_shared::guild::event_kind::is_petition(row.kind) { petition_event_appeared(&context.view, &petition_read(&context.realm), row); } else { guild_event_appeared(&context.view, &guild_membership_read(&context.realm), &guild_roster_snapshot_read(&context.realm), row); } }; + } + game_guild_petition [true] { + insert(Snapshot) => |context, _event, row| charter_petition_opened(&context.view, row); + } + game_guild_member [true] { + insert(Snapshot) => |context, _event, row| guild_membership_changed(&context.view, &guild_membership_read(&context.realm), row.character_guid); + update(Snapshot) => |context, _event, _old, row| guild_membership_changed(&context.view, &guild_membership_read(&context.realm), row.character_guid); + delete(Snapshot) => |context, _event, row| guild_membership_changed(&context.view, &guild_membership_read(&context.realm), row.character_guid); + } +} diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index 45fef633c..db85c6f5a 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -765,3 +765,5 @@ mod tests { assert_eq!(build_random_property_values(&suffix_item([0; 3])), None); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/item/subscriptions.rs b/gateway/src/codec/item/subscriptions.rs new file mode 100644 index 000000000..70a6e3b30 --- /dev/null +++ b/gateway/src/codec/item/subscriptions.rs @@ -0,0 +1,610 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::reads::view_of_item_row; +use crate::stdb::world_index::{EntityLayer, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use lyracore_shared::item_property::client_enchantment_id; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use spacetimedb_sdk::Table; +use std::sync::{Arc, OnceLock}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Trade-status relay: `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the +/// row's recipient and nobody else (audience resolved by the caller, the `auction_notice_outbound` +/// shape). The kind byte is `lyracore_shared::trade::event_kind` — NOT the vanilla discriminant; +/// this match IS the wire mapping. An unknown kind (a newer module mid-rollout) drops with a warn +/// rather than desyncing the window. +pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { + use lyracore_shared::trade::event_kind as kind; + use wow_world_messages::vanilla::SMSG_TRADE_STATUS; + // The OFFER_* kinds carry a whole-side snapshot and decode to the fixed-444-byte extended + // status instead of a plain status ; `self_player` is the kind, not an inference. + if row.kind == kind::OFFER_SELF || row.kind == kind::OFFER_PARTNER { + // WIRE POLARITY (mangoszero `SendUpdateTrade`): the byte is `1 means traders data, + // 0 means own` — so the field is SET when the packet describes the PARTNER's side, + // despite the binding's `self_player` name. The protocol's polarity controls this value. + return match trade_offer_extended(row.kind == kind::OFFER_PARTNER, &row.payload) { + Some(msg) => vec![Outbound::One( + ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(Box::new(msg)), + )], + None => { + log::warn!( + "trade relay: unparseable offer payload {:?} (event {})", + row.payload, + row.id + ); + Vec::new() + } + }; + } + let status = match row.kind { + kind::BEGIN_TRADE => Some(SMSG_TRADE_STATUS::BeginTrade { + unknown1: wow_world_messages::Guid::new(row.other_guid), + }), + kind::OPEN_WINDOW => Some(SMSG_TRADE_STATUS::OpenWindow), + kind::TRADE_CANCELED => Some(SMSG_TRADE_STATUS::TradeCanceled), + kind::BUSY => Some(SMSG_TRADE_STATUS::Busy), + kind::NO_TARGET => Some(SMSG_TRADE_STATUS::NoTarget), + kind::TARGET_TO_FAR => Some(SMSG_TRADE_STATUS::TargetToFar), + kind::WRONG_FACTION => Some(SMSG_TRADE_STATUS::WrongFaction), + kind::YOU_DEAD => Some(SMSG_TRADE_STATUS::YouDead), + kind::TARGET_DEAD => Some(SMSG_TRADE_STATUS::TargetDead), + kind::IGNORE_YOU => Some(SMSG_TRADE_STATUS::IgnoreYou), + kind::TRADE_ACCEPT => Some(SMSG_TRADE_STATUS::TradeAccept), + kind::BACK_TO_TRADE => Some(SMSG_TRADE_STATUS::BackToTrade), + kind::TRADE_COMPLETE => Some(SMSG_TRADE_STATUS::TradeComplete), + // Commit refused on bag space: the window closes with the inventory error; + // `target_error` says WHOSE bags — false = yours, true = the partner's. + kind::INV_FULL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { + inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, + item_limit_category_id: 0, + target_error: false, + }), + kind::INV_FULL_PARTNER => Some(SMSG_TRADE_STATUS::CloseWindow { + inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, + item_limit_category_id: 0, + target_error: true, + }), + kind::GOLD_FAIL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { + inventory_result: wow_world_messages::vanilla::InventoryResult::NotEnoughMoney, + item_limit_category_id: 0, + target_error: false, + }), + kind::GOLD_FAIL_PARTNER => Some(SMSG_TRADE_STATUS::CloseWindow { + inventory_result: wow_world_messages::vanilla::InventoryResult::NotEnoughMoney, + item_limit_category_id: 0, + target_error: true, + }), + other => { + log::warn!("trade relay: unknown kind {other} (event {})", row.id); + None + } + }; + match status { + Some(s) => vec![Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS( + Box::new(s), + ))], + None => Vec::new(), + } +} + +/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED`: +/// counts are 7/7 (the cmangos constant), unused slots stay zeroed (`TradeSlot::default`), and +/// every filled slot carries the module-resolved stack/durability/enchant fields. Fails closed +/// with the payload decoder. `describes_partner` sets the wire's misnamed `self_player` byte — +/// mangoszero: "1 means traders data, 0 means own". +pub(crate) fn trade_offer_extended( + describes_partner: bool, + payload: &str, +) -> Option { + use wow_world_messages::vanilla::{TradeSlot, SMSG_TRADE_STATUS_EXTENDED}; + let (gold, views) = lyracore_shared::trade::decode_offer(payload)?; + let mut trade_slots = [TradeSlot::default(); 7]; + for v in views { + let slot = trade_slots.get_mut(v.trade_slot as usize)?; + *slot = TradeSlot { + trade_slot_number: v.trade_slot, + item: v.entry, + display_id: v.display_id, + stack_count: v.stack_count, + enchantment: client_enchantment_id(v.enchantment), + item_random_properties_id: v.random_property_id, + max_durability: v.max_durability, + durability: v.durability, + ..TradeSlot::default() + }; + } + Some(SMSG_TRADE_STATUS_EXTENDED { + // `1 means traders data` — see the call-site polarity note. + self_player: describes_partner, + trade_slot_count1: 7, + trade_slot_count2: 7, + money_in_trade: wow_world_messages::vanilla::Gold::new(gold), + spell_on_lowest_slot: 0, + trade_slots, + }) +} + +pub(crate) fn item_gain_feedback( + db: &RemoteTables, + self_guid: u64, + slot: u8, + entry: u32, + gained: u32, + stack_add: bool, + random_property_id: u32, +) -> Vec { + if gained == 0 { + return Vec::new(); + } + let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT( + Box::new(codec::build_item_push_result( + self_guid, + codec::bag_content_parts(slot).map_or(255, |(bag, _)| bag), + u32::from(codec::bag_content_parts(slot).map_or(slot, |(_, position)| position)), + entry, + gained, + stack_add, + random_property_id, + )), + ))]; + let wanted = db.game_character_quest().iter().any(|q| { + q.character_guid == self_guid + && !q.rewarded + && !q.failed + && db + .game_quest_objective() + .iter() + .any(|o| o.quest_entry == q.quest_entry && o.kind == 1 && o.target_entry == entry) + }); + if wanted { + use wow_world_messages::vanilla::SMSG_QUESTUPDATE_ADD_ITEM; + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_ITEM(SMSG_QUESTUPDATE_ADD_ITEM { + required_item_id: entry, + items_required: gained, + }), + )); + } + out +} + +/// Build the complete live-inventory insert result for one owner. +pub(crate) fn item_instance_insert_outbound( + db: &RemoteTables, + self_guid: u64, + row: &ItemInstance, + enchantment: u32, +) -> Vec { + let mut out = Vec::new(); + let (max_durability, container_slots) = db + .game_item_template() + .entry() + .find(&row.entry) + .map(|t| (t.max_durability, t.container_slots)) + .unwrap_or((row.durability, 0)); + let view = codec::ItemInstanceView { + max_durability, + container_slots, + random_property_enchant_ids: crate::stdb::reads::property_enchant_ids( + db, + row.random_property_id, + ), + enchantment, + ..view_of_item_row(row) + }; + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_item_create_object(&view)), + ))); + if let Some((opcode, body)) = codec::build_random_property_values(&view) { + out.push(Outbound::Raw { opcode, body }); + } + if let Some(values) = codec::build_inv_slot_values(self_guid, row.slot, row.guid) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, &view) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { + if let Some(bag) = db + .game_item_instance() + .iter() + .find(|item| item.owner_guid == self_guid && item.slot == bag_slot) + { + let (opcode, body) = + codec::build_container_slot_values(bag.guid, slot_in_bag, row.guid); + out.push(Outbound::Raw { opcode, body }); + } + } + // The item object and the descriptor that makes it reachable must precede gain feedback. The + // shared owner-view enqueues this complete vector as one writer job, so another producer cannot + // splice quest completion between CREATE and its inventory/container pointer. + out.extend(item_gain_feedback( + db, + self_guid, + row.slot, + row.entry, + row.stack_count, + false, + row.random_property_id, + )); + if row.slot <= 18 { + append_item_armor_and_sheet(db, self_guid, &mut out); + } + out +} + +/// Build the complete live-inventory delete result for one owner. +pub(crate) fn item_instance_delete_outbound( + db: &RemoteTables, + self_guid: u64, + row: &ItemInstance, +) -> Vec { + let mut out = Vec::new(); + if let Some(values) = codec::build_inv_slot_values(self_guid, row.slot, 0) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + if let Some(values) = + codec::build_visible_item_values(self_guid, row.slot, &codec::ItemInstanceView::default()) + { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { + if let Some(bag) = db + .game_item_instance() + .iter() + .find(|item| item.owner_guid == self_guid && item.slot == bag_slot) + { + let (opcode, body) = codec::build_container_slot_values(bag.guid, slot_in_bag, 0); + out.push(Outbound::Raw { opcode, body }); + } + } + out.push(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(row.guid), + ))); + if row.slot <= 18 { + append_item_armor_and_sheet(db, self_guid, &mut out); + } + out +} + +/// Build the complete live-inventory update result for one owner. +pub(crate) fn item_instance_update_outbound( + db: &RemoteTables, + self_guid: u64, + old: &ItemInstance, + row: &ItemInstance, +) -> Vec { + let old_slot_is_empty = old.slot != row.slot + && !db + .game_item_instance() + .iter() + .any(|item| item.owner_guid == self_guid && item.slot == old.slot); + let mut out = if row.slot == old.slot && row.stack_count > old.stack_count { + item_gain_feedback( + db, + self_guid, + row.slot, + row.entry, + row.stack_count - old.stack_count, + true, + row.random_property_id, + ) + } else { + Vec::new() + }; + if old.slot != row.slot { + append_final_item_slots(self_guid, old.slot, row.slot, &mut out, |slot| { + db.game_item_instance() + .iter() + .find(|item| item.owner_guid == self_guid && item.slot == slot) + .map(|item| view_of_item_row(&item)) + }); + } + out.extend(enchant_change_outbound(self_guid, old, row)); + if old.stack_count != row.stack_count || old.durability != row.durability { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_item_values( + row.guid, + row.stack_count, + row.durability, + )), + ))); + } + let moved_into_equipment = old.slot != row.slot && row.slot <= 18; + let emptied_equipment = old.slot != row.slot && old.slot <= 18 && old_slot_is_empty; + if moved_into_equipment + || emptied_equipment + || row.slot <= 18 + && ((old.durability == 0) != (row.durability == 0) || old.enchant_id != row.enchant_id) + { + append_item_armor_and_sheet(db, self_guid, &mut out); + } + out +} + +/// An in-place permanent enchant change: the item's enchantment word and, for worn gear that did +/// not change slot, its visible item. The slot projection covers a change of slot. Empty when the +/// enchant did not change, and for a Guild Charter, whose word holds its Petition id. +pub(crate) fn enchant_change_outbound( + self_guid: u64, + old: &ItemInstance, + row: &ItemInstance, +) -> Vec { + if old.enchant_id == row.enchant_id || row.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY + { + return Vec::new(); + } + let view = view_of_item_row(row); + let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_item_enchantment_values( + row.guid, + view.enchantment, + )), + ))]; + if old.slot == row.slot { + if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, &view) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + } + out +} + +pub(crate) fn append_final_item_slots( + self_guid: u64, + old_slot: u8, + new_slot: u8, + out: &mut Vec, + item_in_slot: impl Fn(u8) -> Option, +) { + let old_item = item_in_slot(old_slot); + let new_item = item_in_slot(new_slot).unwrap_or_default(); + let slots = old_item + .is_none() + .then_some((old_slot, codec::ItemInstanceView::default())) + .into_iter() + .chain([(new_slot, new_item)]); + for (slot, item) in slots.clone() { + if let Some(values) = codec::build_inv_slot_values(self_guid, slot, item.guid) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + } + for (slot, item) in slots.clone() { + if let Some(values) = codec::build_visible_item_values(self_guid, slot, &item) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))); + } + } + for (slot, item) in slots { + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(slot) { + if let Some(bag) = item_in_slot(bag_slot) { + let (opcode, body) = + codec::build_container_slot_values(bag.guid, slot_in_bag, item.guid); + out.push(Outbound::Raw { opcode, body }); + } + } + } +} + +pub(crate) fn append_item_armor_and_sheet( + db: &RemoteTables, + self_guid: u64, + out: &mut Vec, +) { + let resistances = crate::stdb::armor::effective_resistances(db, self_guid); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_resistance_values(self_guid, resistances)), + ))); + if let Some(stats) = crate::stdb::armor::sheet_stats(db, self_guid) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_sheet_stats_values(self_guid, &stats)), + ))); + } +} + +/// Select one item owner registered on the firing shard; cache reads and packet work run on its +/// writer queue. +pub(crate) fn item_owner_job( + view: &WorldView, + shard: ShardId, + owner_guid: u64, + job: impl FnOnce(Arc) -> Vec + Send + 'static, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(owner_guid)) else { + return; + }; + enqueue(viewer, job); +} + +pub(crate) fn item_inserted( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &ItemInstance, +) { + item_visible_changed(view, shard, None, Some(row), item_slot_read(coord)); + let (coord, row) = (coord.clone(), row.clone()); + item_owner_job(view, shard, row.owner_guid, move |viewer| { + let enchantment = item_enchantment_word(&coord, &row); + let guard = coord.0.coord(); + crate::codec::item::subscriptions::item_instance_insert_outbound( + &guard.conn.db, + viewer.self_guid, + &row, + enchantment, + ) + }); +} + +/// The value of ITEM_FIELD_ENCHANTMENT for a newly inserted item. A Guild Charter shows its Petition +/// id, read from the Realm-core cache before the Home Shard's cache guard is taken; it shows 0 when +/// its Petition is not open yet, and the Petition insert relay sends it later. Every other item +/// shows the enchantment ID the client resolves for its permanent enchant. +pub(crate) fn item_enchantment_word(coord: &Coordinator, row: &ItemInstance) -> u32 { + if row.entry != lyracore_shared::guild::GUILD_CHARTER_ENTRY { + return client_enchantment_id(row.enchant_id); + } + coord + .realm_core() + .ok() + .and_then(|realm| realm.charter_petition_id(row.guid)) + .unwrap_or(0) +} + +pub(crate) fn item_updated( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + old: &ItemInstance, + row: &ItemInstance, +) { + item_visible_changed(view, shard, Some(old), Some(row), item_slot_read(coord)); + let (coord, old, row) = (coord.clone(), old.clone(), row.clone()); + item_owner_job(view, shard, row.owner_guid, move |viewer| { + let guard = coord.0.coord(); + crate::codec::item::subscriptions::item_instance_update_outbound( + &guard.conn.db, + viewer.self_guid, + &old, + &row, + ) + }); +} + +pub(crate) fn item_deleted( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &ItemInstance, +) { + item_visible_changed(view, shard, Some(row), None, item_slot_read(coord)); + let (coord, row) = (coord.clone(), row.clone()); + item_owner_job(view, shard, row.owner_guid, move |viewer| { + let guard = coord.0.coord(); + crate::codec::item::subscriptions::item_instance_delete_outbound( + &guard.conn.db, + viewer.self_guid, + &row, + ) + }); +} + +pub(crate) type ItemSlotRead = + Arc Option + Send + Sync>; + +pub(crate) fn item_slot_read(coord: &Coordinator) -> ItemSlotRead { + let coord = coord.clone(); + Arc::new(move |owner_guid, slot| { + coord + .0 + .coord() + .conn + .db + .game_item_instance() + .iter() + .find(|item| item.owner_guid == owner_guid && item.slot == slot) + .map(|item| crate::stdb::reads::view_of_item_row(&item)) + }) +} + +/// Peers see worn gear only. The first eligible writer reads the current slot occupant; +/// all recipients share it so the item table is scanned once per changed slot. +pub(crate) fn item_visible_changed( + view: &WorldView, + shard: ShardId, + old: Option<&ItemInstance>, + new: Option<&ItemInstance>, + item_in_slot: ItemSlotRead, +) { + let visible = |row: &ItemInstance| { + ( + row.owner_guid, + row.slot, + row.entry, + row.random_property_id, + row.enchant_id, + ) + }; + if old.map(visible) == new.map(visible) { + return; + } + let mut slots = Vec::with_capacity(2); + for row in old.into_iter().chain(new) { + let slot = (row.owner_guid, row.slot); + if row.slot <= 18 && !slots.contains(&slot) { + slots.push(slot); + } + } + for (owner_guid, slot) in slots { + let item = Arc::new(OnceLock::new()); + let key = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, owner_guid, shard); + for viewer in view.cell_audience(shard, key, BOX_HALF_SPAN, &[]) { + if viewer.self_guid == owner_guid { + continue; + } + let item_in_slot = item_in_slot.clone(); + let item = item.clone(); + enqueue(viewer, move |viewer| { + if !viewer.created.lock().unwrap().contains(&owner_guid) { + return Vec::new(); + } + let item = item.get_or_init(|| item_in_slot(owner_guid, slot).unwrap_or_default()); + crate::codec::build_visible_item_values(owner_guid, slot, item) + .map(|values| { + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))] + }) + .unwrap_or_default() + }); + } + } +} + +/// A trade status landed → the `SMSG_TRADE_STATUS` packet to the row's RECIPIENT and nobody +/// else (same shape as [`resurrect_offered`]). +pub(crate) fn trade_event_appeared(view: &WorldView, shard: ShardId, row: &TradeEvent) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) { + return; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::item::subscriptions::trade_event_outbound(&row) + }); +} + +crate::stdb::relays::declare_relays! { + game_item_instance [false] { + insert(Live) => |context, _event, row| item_inserted(&context.view, &context.coord, context.shard, row); + update(Snapshot) => |context, _event, old, row| item_updated(&context.view, &context.coord, context.shard, old, row); + delete(Snapshot) => |context, _event, row| item_deleted(&context.view, &context.coord, context.shard, row); + } + game_trade_event [false] { + insert(Live) => |context, _event, row| trade_event_appeared(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/leveling.rs b/gateway/src/codec/leveling.rs index f53cba0a4..c30594a2c 100644 --- a/gateway/src/codec/leveling.rs +++ b/gateway/src/codec/leveling.rs @@ -115,3 +115,5 @@ mod tests { assert_eq!(msg.happiness, 0); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/leveling/subscriptions.rs b/gateway/src/codec/leveling/subscriptions.rs new file mode 100644 index 000000000..9c976194d --- /dev/null +++ b/gateway/src/codec/leveling/subscriptions.rs @@ -0,0 +1,161 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use spacetimedb_sdk::Table; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Build the XP-gain result for one identity-addressed row. +pub(crate) fn xp_event_outbound(row: &XpEvent) -> Vec { + let (opcode, body) = codec::build_log_xpgain_raw( + row.killed_guid, + row.total_exp, + row.is_kill, + row.rested_bonus, + ); + vec![Outbound::Raw { opcode, body }] +} + +/// Build the level-up result for one identity-addressed row. +pub(crate) fn levelup_event_outbound(row: &LevelupEvent) -> Vec { + let message = codec::build_levelup_info( + row.new_level, + row.health_gained, + row.mana_gained, + row.strength_gained, + row.agility_gained, + row.stamina_gained, + row.intellect_gained, + row.spirit_gained, + ); + vec![Outbound::One(ServerOpcodeMessage::SMSG_LEVELUP_INFO( + Box::new(message), + ))] +} + +/// The shared-dispatch twin of the per-player +/// `on_rest_insert` relay (rest state). Same raw PLAYER_BYTES_2 VALUES packet; the audience (owner +/// only) was already resolved by the owner-session lookup in `world_view::rest_state_appeared`. +pub(crate) fn relay_rest_state(self_guid: u64, player_bytes_2: u32) -> Vec { + let (opcode, body) = codec::build_rest_state_values(self_guid, player_bytes_2); + vec![Outbound::Raw { opcode, body }] +} + +/// XP rows are delivered to the one viewer bound to their recipient identity. +pub(crate) fn xp_appeared(view: &WorldView, shard: ShardId, row: &XpEvent) { + let Some(viewer) = + view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) + else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::leveling::subscriptions::xp_event_outbound(&row) + }); +} + +/// Level-up rows are delivered to the one viewer bound to their recipient identity. +pub(crate) fn levelup_appeared(view: &WorldView, shard: ShardId, row: &LevelupEvent) { + let Some(viewer) = + view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) + else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::leveling::subscriptions::levelup_event_outbound(&row) + }); +} + +/// An explored-area row restores the complete fog word first, then reports a new discovery. +/// Cache reads and replay-state mutation stay on the addressed session's writer queue. +pub(crate) fn exploration_appeared( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &CharacterExplored, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (row, coord) = (row.clone(), coord.clone()); + enqueue(viewer.clone(), move |viewer| { + exploration_outbound(&coord, &viewer, &row) + }); +} + +pub(crate) fn exploration_outbound( + coord: &Coordinator, + viewer: &Viewer, + row: &CharacterExplored, +) -> Vec { + if row.area_bit < 0 { + return Vec::new(); + } + let word_idx = (row.area_bit / 32) as u16; + let lo = word_idx as i32 * 32; + let word = { + let guard = coord.0.coord(); + guard + .conn + .db + .game_character_explored() + .iter() + .filter(|candidate| { + candidate.character_guid == viewer.self_guid + && candidate.area_bit >= lo + && candidate.area_bit < lo + 32 + }) + .fold(0u32, |word, candidate| { + word | (1u32 << (candidate.area_bit - lo)) + }) + }; + exploration_outbound_for_word(viewer, row, word_idx, word) +} + +pub(crate) fn exploration_outbound_for_word( + viewer: &Viewer, + row: &CharacterExplored, + word_idx: u16, + word: u32, +) -> Vec { + let (opcode, body) = + crate::codec::build_explored_zones_values(viewer.self_guid, word_idx, word); + let mut outbound = vec![Outbound::Raw { opcode, body }]; + if viewer.explored.lock().unwrap().admit_once(row.area_bit) { + let (opcode, body) = + crate::codec::build_exploration_experience_raw(row.area_id, row.experience); + outbound.push(Outbound::Raw { opcode, body }); + } + outbound +} + +/// Send a rest-state change to its owner on the callback's Shard. +pub(crate) fn rest_state_appeared(view: &WorldView, shard: ShardId, row: &RestStateEvent) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (self_guid, bytes) = (row.character_guid, row.player_bytes_2); + enqueue(viewer.clone(), move |_| { + crate::codec::leveling::subscriptions::relay_rest_state(self_guid, bytes) + }); +} + +crate::stdb::relays::declare_relays! { + game_xp_event [false] { + insert(Live) => |context, _event, row| xp_appeared(&context.view, context.shard, row); + } + game_levelup_event [false] { + insert(Live) => |context, _event, row| levelup_appeared(&context.view, context.shard, row); + } + game_character_explored [false] { + insert(Snapshot) => |context, _event, row| exploration_appeared(&context.view, &context.coord, context.shard, row); + } + game_rest_state_event [false] { + insert(Snapshot) => |context, _event, row| rest_state_appeared(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/mail.rs b/gateway/src/codec/mail.rs index 185cd5c82..07b7a1e6b 100644 --- a/gateway/src/codec/mail.rs +++ b/gateway/src/codec/mail.rs @@ -648,3 +648,5 @@ mod tests { assert_eq!(body, [0, 0, 0, 0], "cmangos Player.cpp:3074-3075"); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/mail/subscriptions.rs b/gateway/src/codec/mail/subscriptions.rs new file mode 100644 index 000000000..d19065235 --- /dev/null +++ b/gateway/src/codec/mail/subscriptions.rs @@ -0,0 +1,41 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Build a Mail Arrival after the caller validates the recipient. +pub(crate) fn mail_arrival_outbound() -> Vec { + vec![Outbound::One(ServerOpcodeMessage::SMSG_RECEIVED_MAIL( + codec::build_received_mail(), + ))] +} + +/// A Mail became visible to its recipient → SMSG_RECEIVED_MAIL to that recipient's World Session on +/// any Shard. An offline recipient gets nothing and learns of the Mail from the unread poll at the +/// next login (cmangos `Player.cpp:3098-3110`, `MailHandler.cpp:712-720`). +pub(crate) fn mail_arrived(view: &WorldView, row: &MailArrival) { + let Some(session) = view.session_of_owner(row.recipient_guid) else { + return; + }; + let Some(viewer) = view.viewer(session) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) { + return; + } + enqueue(viewer.clone(), |_| { + crate::codec::mail::subscriptions::mail_arrival_outbound() + }); +} + +crate::stdb::relays::declare_relays! { + game_mail_arrival [true] { + insert(Live) => |context, _event, row| mail_arrived(&context.view, row); + } +} diff --git a/gateway/src/codec/mod.rs b/gateway/src/codec/mod.rs index 1d99fa15d..c64994479 100644 --- a/gateway/src/codec/mod.rs +++ b/gateway/src/codec/mod.rs @@ -21,30 +21,30 @@ pub mod social; pub mod update_mask; // Packet-family submodules. -mod auction; -mod breath; +pub(crate) mod auction; +pub(crate) mod breath; mod char; -mod combat; -mod corpse; -mod duel; -mod entity; -mod gameobject; -mod guild; -mod item; -mod leveling; +pub(crate) mod combat; +pub(crate) mod corpse; +pub(crate) mod duel; +pub(crate) mod entity; +pub(crate) mod gameobject; +pub(crate) mod guild; +pub(crate) mod item; +pub(crate) mod leveling; mod loot; -mod mail; +pub(crate) mod mail; mod meeting_stone; mod member_stats; -mod movement; +pub(crate) mod movement; mod movement_info; mod npc; -mod pet; -mod quest; -mod taxi; -mod trainer; +pub(crate) mod pet; +pub(crate) mod quest; +pub(crate) mod taxi; +pub(crate) mod trainer; mod values; -mod weather; +pub(crate) mod weather; pub use social::{ build_chat_message_to, build_emote_anim, build_friend_list_response, build_friend_status_raw, diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index e5025773d..429ff9949 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -418,3 +418,5 @@ mod ground_path_tests { } } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/movement/subscriptions.rs b/gateway/src/codec/movement/subscriptions.rs new file mode 100644 index 000000000..c1e672f7b --- /dev/null +++ b/gateway/src/codec/movement/subscriptions.rs @@ -0,0 +1,344 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::subscriptions::{MOTION_CALLS, MOTION_SENT}; +use crate::stdb::world_index::{CellKey, ShardId}; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use anyhow::Result; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use std::collections::HashSet; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +use wow_world_messages::vanilla::Vector3d; + +/// Build the wire relay for one `game_teleport_event` row. Pure: takes the +/// already-derived `still_here` boolean (whether the live entity survived the transaction — the +/// module/gateway-mirrored same-map/cross-map signal, see the `on_teleport` callback's doc comment) +/// instead of a live `ReducerContext`/connection, so it's testable without a DB (module crate +/// convention: extract pure functions and test those). +/// +/// `still_here` → same-map: `MSG_MOVE_TELEPORT_ACK` (byte-identical to the previous behavior — the ONLY +/// caller-visible difference from before is that this is now reached via a branch rather than +/// unconditionally). NOT `still_here` → cross-map: `SMSG_TRANSFER_PENDING`+`SMSG_NEW_WORLD` as one batch +/// (`codec::build_cross_map_teleport`). `Err` only for a `map_id` the client's `Map` enum doesn't know +/// (propagated so the caller can log it — never silently drops the relay). +pub(crate) fn build_teleport_relay( + still_here: bool, + mover_guid: u64, + map_id: u32, + x: f32, + y: f32, + z: f32, + o: f32, +) -> Result { + if still_here { + let m = codec::build_teleport_ack(mover_guid, 0, x, y, z, o); + Ok(Outbound::One(ServerOpcodeMessage::MSG_MOVE_TELEPORT_ACK( + Box::new(m), + ))) + } else { + let [pending, new_world] = codec::build_cross_map_teleport(map_id, x, y, z, o)?; + Ok(Outbound::Batch(vec![pending, new_world])) + } +} + +/// Build the complete teleport result on the addressed session's writer queue. +pub(crate) fn teleport_event_outbound(row: &TeleportEvent) -> Vec { + match build_teleport_relay( + !row.cross_map, + row.mover_guid, + row.map_id, + row.x, + row.y, + row.z, + row.orientation, + ) { + Ok(out) => vec![out], + Err(error) => { + log::warn!("teleport relay: {error} (guid {})", row.mover_guid); + Vec::new() + } + } +} + +pub(crate) fn motion_outbound( + created: &Mutex>, + self_guid: u64, + row: &EntityMotion, +) -> Vec { + MOTION_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if row.guid == self_guid || !created.lock().unwrap().contains(&row.guid) { + return Vec::new(); + } + match codec::build_movement_relay_raw(row.opcode as u32, row.guid, &row.movement_info) { + Some((opcode, body)) => { + MOTION_SENT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + vec![Outbound::Raw { opcode, body }] + } + None if lyracore_shared::opcodes::movement::is_slice_move(row.opcode as u32) => { + log::warn!( + "motion relay: malformed movement_info ({} bytes) for mover {}", + row.movement_info.len(), + row.guid + ); + Vec::new() + } + None => Vec::new(), + } +} + +pub(crate) fn creature_leg_outbound( + created: &Mutex>, + row: &CreatureSpline, +) -> Vec { + if !created.lock().unwrap().contains(&row.guid) { + return Vec::new(); + } + let start = Vector3d { + x: row.sx, + y: row.sy, + z: row.sz, + }; + // a facing-only leg (the mover doesn't move, `emit_facing_spline`'s carrier) gets the + // `FacingAngle` variant — the only 1.12 `SMSG_MONSTER_MOVE` shape that turns a STATIONARY + // creature without a spline the client would otherwise have nothing to interpolate from. + if row.facing { + return vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( + Box::new(codec::build_monster_move_facing( + row.guid, + start, + row.facing_angle, + row.spline_id, + )), + ))]; + } + if let Some(path) = &row.path { + let points = &path.points; + let points: Vec<_> = points.iter().map(|p| (p.x, p.y, p.z)).collect(); + if let Some((opcode, body)) = codec::build_ground_path_raw( + row.guid, + start, + &points, + row.dur_ms, + row.spline_id, + row.run, + ) { + return vec![Outbound::Raw { opcode, body }]; + } + log::warn!( + "refused malformed creature path for guid {} spline {}", + row.guid, + row.spline_id + ); + if [start.x, start.y, start.z].iter().all(|v| v.is_finite()) { + return vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( + Box::new(codec::build_monster_move( + row.guid, + start, + start, + 0, + row.spline_id, + false, + )), + ))]; + } + return Vec::new(); + } + let dest = Vector3d { + x: row.dx, + y: row.dy, + z: row.dz, + }; + vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( + Box::new(codec::build_monster_move( + row.guid, + start, + dest, + row.dur_ms, + row.spline_id, + row.run, + )), + ))] +} + +pub(crate) fn append_resident_creature_after_create( + created_outbound: &mut Vec, + created: &Mutex>, + viewer_instance_id: u64, + row: Option<&CreatureSpline>, + now_micros: u64, +) { + let Some(row) = row else { + return; + }; + if row.instance_id != viewer_instance_id { + return; + } + if row.facing { + created_outbound.extend(creature_leg_outbound(created, row)); + return; + } + + let duration_micros = u64::from(row.dur_ms) * 1_000; + let elapsed_micros = now_micros.saturating_sub(row.start_micros); + if duration_micros == 0 || elapsed_micros >= duration_micros { + return; + } + + let elapsed_fraction = elapsed_micros as f64 / duration_micros as f64; + let current = |start: f32, destination: f32| { + (f64::from(start) + (f64::from(destination) - f64::from(start)) * elapsed_fraction) as f32 + }; + let mut remaining = row.clone(); + remaining.sx = current(row.sx, row.dx); + remaining.sy = current(row.sy, row.dy); + remaining.sz = current(row.sz, row.dz); + if let Some(path) = &row.path { + let points = &path.points; + let points: Vec<_> = points.iter().map(|p| (p.x, p.y, p.z)).collect(); + let (at, next) = lyracore_shared::movement_path::sample( + (row.sx, row.sy, row.sz), + &points, + elapsed_fraction as f32, + ); + (remaining.sx, remaining.sy, remaining.sz) = at; + if let Some(path) = &mut remaining.path { + path.points = path.points[next..].to_vec(); + } + } + remaining.dur_ms = (duration_micros - elapsed_micros).div_ceil(1_000) as u32; + created_outbound.extend(creature_leg_outbound(created, &remaining)); +} + +pub(crate) fn unix_now_micros() -> u64 { + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(); + micros.min(u128::from(u64::MAX)) as u64 +} + +pub(crate) fn motion( + view: &WorldView, + shard: ShardId, + row: &EntityMotion, + delivery: &crate::stdb::movement_batch::MotionDelivery, +) { + delivery.received(); + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + let row = Arc::new(row.clone()); + // The MOVER is deliberately not a recipient (`world_entity_recipients`' self leg is skipped + // here): a + // player must never receive their own movement echoed back, or they fight the server for + // authority over their position. `relay_entity_motion` re-checks it anyway. + for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { + if viewer.self_guid == row.guid { + continue; + } + // SUPERSEDE, don't append — the viewer needs only this mover's latest position, so + // the upsert replaces any not-yet-flushed heartbeat from the same mover and the queue + // carries at most one flush job regardless of crowd size. + viewer + .motion_pending + .entity + .lock() + .unwrap() + .insert(row.guid, row.clone()); + maybe_queue_motion_flush(view, &viewer); + } +} + +pub(crate) fn creature_leg(view: &WorldView, shard: ShardId, row: &CreatureSpline) { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + let row = Arc::new(row.clone()); + for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { + // Same supersession as player motion — latest leg per creature wins. + viewer + .motion_pending + .creature + .lock() + .unwrap() + .insert(row.guid, row.clone()); + maybe_queue_motion_flush(view, &viewer); + } +} + +/// Queue this viewer's ONE motion-flush job, unless one is already queued. The egress shed +/// decision keeps its pump-side, one-atomic-load shape (task B2's contract): a shed drops the +/// FLUSH — the pending map keeps superseding meanwhile, the flag resets so a later heartbeat +/// retries, and nothing is stranded. +pub(crate) fn maybe_queue_motion_flush(_view: &WorldView, viewer: &Arc) { + use std::sync::atomic::Ordering as AtOrd; + if viewer.motion_pending.flush_queued.swap(true, AtOrd::AcqRel) { + return; // a queued flush will carry the upsert that just landed + } + if crate::stdb::subscriptions::shed_motion_at_depth(viewer.tx.depth()) { + crate::stdb::subscriptions::MOTION_DROPPED.fetch_add(1, Ordering::Relaxed); + viewer + .motion_pending + .flush_queued + .store(false, AtOrd::Release); + return; + } + let viewer = viewer.clone(); + enqueue(viewer.clone(), move |viewer| { + // Clear BEFORE draining: an upsert racing the drain re-queues a follow-up flush (possibly + // empty — harmless) instead of being stranded behind a still-set flag. + viewer + .motion_pending + .flush_queued + .store(false, AtOrd::Release); + let moves: Vec> = { + let mut m = viewer.motion_pending.entity.lock().unwrap(); + m.drain().map(|(_, r)| r).collect() + }; + let legs: Vec> = { + let mut m = viewer.motion_pending.creature.lock().unwrap(); + m.drain().map(|(_, r)| r).collect() + }; + let mut out = Vec::new(); + for row in moves { + out.extend(crate::codec::movement::subscriptions::motion_outbound( + &viewer.created, + viewer.self_guid, + &row, + )); + } + for row in legs { + out.extend( + crate::codec::movement::subscriptions::creature_leg_outbound(&viewer.created, &row), + ); + } + out + }); +} + +/// A teleport row names one character owner on this shard. Packet construction stays on that +/// viewer's writer. +pub(crate) fn teleport_appeared(view: &WorldView, shard: ShardId, row: &TeleportEvent) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.mover_guid)) else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::movement::subscriptions::teleport_event_outbound(&row) + }); +} + +crate::stdb::relays::declare_relays! { + game_teleport_event [false] { + insert(Live) => |context, _event, row| teleport_appeared(&context.view, context.shard, row); + } + game_entity_motion [false] { + insert(Snapshot) => |context, _event, row| motion(&context.view, context.shard, row, &context.coord.0.motion_batch.delivery); + update(Snapshot) => |context, _event, _old, row| motion(&context.view, context.shard, row, &context.coord.0.motion_batch.delivery); + } + game_creature_spline [false] { + insert(Snapshot) => |context, _event, row| creature_leg(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| creature_leg(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/pet.rs b/gateway/src/codec/pet.rs index d881ceaa7..6b12bd027 100644 --- a/gateway/src/codec/pet.rs +++ b/gateway/src/codec/pet.rs @@ -138,3 +138,5 @@ mod tests { ); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/pet/subscriptions.rs b/gateway/src/codec/pet/subscriptions.rs new file mode 100644 index 000000000..cb5f97c12 --- /dev/null +++ b/gateway/src/codec/pet/subscriptions.rs @@ -0,0 +1,40 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::views::hunter_pet_protocol_view; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Build the owner's Hunter pet descriptor VALUES for one owner-addressed row. +pub(crate) fn hunter_pet_outbound(row: &HunterPetProtocol) -> Vec { + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_hunter_pet_values(&hunter_pet_protocol_view( + row.clone(), + ))), + ))] +} + +/// Hunter pet descriptors are idempotent VALUES, so replay re-sends are state-sync, not toasts. +pub(crate) fn hunter_pet_appeared(view: &WorldView, shard: ShardId, row: &HunterPetProtocol) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { + return; + }; + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::pet::subscriptions::hunter_pet_outbound(&row) + }); +} + +crate::stdb::relays::declare_relays! { + game_hunter_pet_protocol [false] { + insert(Snapshot) => |context, _event, row| hunter_pet_appeared(&context.view, context.shard, row); + update(Snapshot) => |context, _event, old, row| { + if old != row { + hunter_pet_appeared(&context.view, context.shard, row) + } + }; + } +} diff --git a/gateway/src/codec/quest.rs b/gateway/src/codec/quest.rs index 67ccb98c4..4a5821b4c 100644 --- a/gateway/src/codec/quest.rs +++ b/gateway/src/codec/quest.rs @@ -871,3 +871,5 @@ mod tests { assert_eq!(comp.item_rewards.len(), 1); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/quest/subscriptions.rs b/gateway/src/codec/quest/subscriptions.rs new file mode 100644 index 000000000..f935e71b9 --- /dev/null +++ b/gateway/src/codec/quest/subscriptions.rs @@ -0,0 +1,197 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use spacetimedb_sdk::Table; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Build the full quest-log update from the coordinator cache. The empty mask deliberately clears +/// every quest-log field when the final quest leaves the log. +pub(crate) fn quest_log_outbound( + db: &crate::stdb::bindings::RemoteTables, + self_guid: u64, +) -> Option { + if !crate::config::quest_log_fields_enabled() { + return None; + } + let quests: Vec<_> = db.game_character_quest().iter().collect(); + let slots = crate::stdb::reads::build_quest_log_slots(db, &quests, self_guid); + let mask = codec::update_mask::full_quest_log_mask(&slots); + let (opcode, body) = codec::build_values_update_raw(self_guid, &mask); + Some(Outbound::Raw { opcode, body }) +} + +/// Build all packets for one quest update, in the order the client expects. +pub(crate) fn quest_update_outbound( + db: &crate::stdb::bindings::RemoteTables, + self_guid: u64, + old: &CharacterQuest, + row: &CharacterQuest, +) -> Vec { + let objectives: Vec<_> = db + .game_quest_objective() + .iter() + .filter(|objective| objective.quest_entry == row.quest_entry) + .map(|objective| { + ( + objective.kind, + objective.obj_index, + objective.target_entry, + objective.required_count, + ) + }) + .collect(); + quest_update_packets(old, row, &objectives, quest_log_outbound(db, self_guid)) +} + +/// Build the feedback that precedes a full quest-log sync for one update. +pub(crate) fn quest_update_feedback( + old: &CharacterQuest, + row: &CharacterQuest, + objectives: &[(u8, u8, u32, u32)], +) -> Vec { + let mut outbound = + codec::kill_progress_add_kills(row.quest_entry, &old.counts, &row.counts, objectives) + .into_iter() + .map(|packet| { + Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_KILL(Box::new( + packet, + ))) + }) + .collect::>(); + if !old.failed && row.failed { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_QUESTUPDATE_FAILEDTIMER( + codec::build_questupdate_failedtimer(row.quest_entry), + ), + )); + } + outbound +} + +/// Append a post-update full-log sync after kill and timer feedback. +pub(crate) fn quest_update_packets( + old: &CharacterQuest, + row: &CharacterQuest, + objectives: &[(u8, u8, u32, u32)], + quest_log: Option, +) -> Vec { + let mut outbound = quest_update_feedback(old, row, objectives); + if let Some(packet) = quest_log { + outbound.push(packet); + } + outbound +} + +/// A quest-log insert names one owner. The full log is read after the delta on that owner's writer. +pub(crate) fn quest_inserted( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &CharacterQuest, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (coord, self_guid) = (coord.clone(), viewer.self_guid); + enqueue(viewer.clone(), move |_| { + let guard = coord.0.coord(); + crate::codec::quest::subscriptions::quest_log_outbound(&guard.conn.db, self_guid) + .into_iter() + .collect() + }); +} + +/// A quest update keeps kill feedback, timer failure, then the full quest-log sync in one writer job. +pub(crate) fn quest_updated( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + old: &CharacterQuest, + row: &CharacterQuest, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (coord, self_guid, old, row) = (coord.clone(), viewer.self_guid, old.clone(), row.clone()); + enqueue(viewer.clone(), move |_| { + let guard = coord.0.coord(); + crate::codec::quest::subscriptions::quest_update_outbound( + &guard.conn.db, + self_guid, + &old, + &row, + ) + }); +} + +/// A quest deletion re-sends the full log, including the all-zero final-log clear. +pub(crate) fn quest_deleted( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &CharacterQuest, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (coord, self_guid) = (coord.clone(), viewer.self_guid); + enqueue(viewer.clone(), move |_| { + let guard = coord.0.coord(); + crate::codec::quest::subscriptions::quest_log_outbound(&guard.conn.db, self_guid) + .into_iter() + .collect() + }); +} + +crate::stdb::relays::declare_relays! { + game_character_quest [false] { + insert(Snapshot) => |context, _event, row| quest_inserted(&context.view, &context.coord, context.shard, row); + update(Snapshot) => |context, _event, old, row| quest_updated(&context.view, &context.coord, context.shard, old, row); + 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); + 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); + } + game_quest_event_requirement [false] { + insert(Snapshot) => |context, _event, _row| quest_requirements_changed(&context.view, &context.coord, context.shard); + update(Snapshot) => |context, _event, _old, _row| quest_requirements_changed(&context.view, &context.coord, context.shard); + delete(Snapshot) => |context, _event, _row| quest_requirements_changed(&context.view, &context.coord, context.shard); + } +} + +fn quest_credit_changed( + 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 = coord.clone(); + enqueue(viewer, move |viewer| { + let guard = coord.0.coord(); + quest_log_outbound(&guard.conn.db, viewer.self_guid) + .into_iter() + .collect() + }); +} + +fn quest_requirements_changed(view: &WorldView, coord: &Coordinator, shard: ShardId) { + for viewer in view.viewers_on_shard(shard) { + let coord = coord.clone(); + enqueue(viewer, move |viewer| { + let guard = coord.0.coord(); + quest_log_outbound(&guard.conn.db, viewer.self_guid) + .into_iter() + .collect() + }); + } +} diff --git a/gateway/src/codec/social.rs b/gateway/src/codec/social.rs index c054dfd5a..395d89591 100644 --- a/gateway/src/codec/social.rs +++ b/gateway/src/codec/social.rs @@ -1353,3 +1353,5 @@ mod party_tests { assert_eq!(result.result, PartyResult::AlreadyInGroup); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs new file mode 100644 index 000000000..141d16469 --- /dev/null +++ b/gateway/src/codec/social/subscriptions.rs @@ -0,0 +1,1160 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::{CellKey, EntityLayer, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::{CharacterStore, Outbound, QuestActionStore, SessionStore, ShardRoutingStore}; +use spacetimedb_sdk::Table; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// SAY proximity range (vanilla ~25yd). See [`chat_in_range`]. +pub(crate) const SAY_RANGE_YD: f32 = 25.0; + +/// YELL proximity range (vanilla ~300yd). Same rationale as `SAY_RANGE_YD`. +pub(crate) const YELL_RANGE_YD: f32 = 300.0; + +#[cfg(test)] +pub(crate) const SAY_RANGE_SQ: f32 = SAY_RANGE_YD * SAY_RANGE_YD; + +#[cfg(test)] +pub(crate) const YELL_RANGE_SQ: f32 = YELL_RANGE_YD * YELL_RANGE_YD; + +/// How far a `game_chat_event` line of this kind carries. Every kind on that table is +/// range-bound: SAY, `/e` and the creature text emote at SAY range, YELL at its own; party, guild +/// and whispers ride other tables. +pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { + if chat_type == lyracore_shared::chat::broadcast_chat::YELL { + YELL_RANGE_YD + } else { + SAY_RANGE_YD + } +} + +/// Group / loot-roll / quest-share: the ONE kind-decode body both legs +/// run. PRIVATE data — the audience (the row's recipient, and nobody else) is resolved by the +/// caller (RLS on the per-player leg; the owner-session lookup + `private_recipient_audience` on +/// the shared leg). `store` is the connection the row came from: Realm-core on a sharded Realm. +/// The LIST render reads every World Shard through it, and QUEST_SHARE reads the quest detail. +#[allow(clippy::too_many_lines)] // One arm per group event kind. +pub(crate) fn group_event_outbound< + St: CharacterStore + QuestActionStore + SessionStore + ShardRoutingStore + ?Sized, +>( + store: &St, + self_guid: u64, + row: &GroupEvent, +) -> Vec { + use lyracore_shared::group::event_kind as group_kind; + use lyracore_shared::loot_roll::event_kind as roll_kind; + use lyracore_shared::meeting_stone::event_kind as stone_kind; + use lyracore_shared::quest::share_event_kind as quest_share_kind; + let msg = match row.kind { + // Sent even when no shard can name the inviter (falls back to an empty name, like + // `render_list` does for an unresolved roster member): the popup is the target's only way + // to learn of the invite, and the pending `game_group_invite` row already committed. + // Skipping the packet would strand it for the whole 2-minute invite GC. + group_kind::INVITE => Some(ServerOpcodeMessage::SMSG_GROUP_INVITE(Box::new( + codec::build_group_invite( + other_character_name(store, row, "INVITE").unwrap_or_default(), + ), + ))), + // The same renderer world entry uses: presence and blank names come from the shard caches, + // because a roster written on realm-core can know neither. A Party's Target Icons follow + // in the same job, because the Party client clears its marks on the list. + group_kind::LIST => match lyracore_shared::group::RosterPayload::decode(&row.payload) { + Some(roster) => { + let mut packets = vec![crate::world::party::render_list(store, self_guid, &roster)]; + if !roster.target_icons.is_empty() { + let (opcode, body) = codec::build_target_icon_list_raw(&roster.target_icons); + packets.push(Outbound::Raw { opcode, body }); + } + return packets; + } + None => { + log::warn!( + "group LIST relay: unparseable roster payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + // Same fallback as INVITE, for the same reason: consistent behavior for the two kinds + // that answer an invite dialog. + group_kind::DECLINE => Some(ServerOpcodeMessage::SMSG_GROUP_DECLINE(Box::new( + codec::build_group_decline( + other_character_name(store, row, "DECLINE").unwrap_or_default(), + ), + ))), + group_kind::DESTROYED => Some(ServerOpcodeMessage::SMSG_GROUP_DESTROYED), + 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, + ))) + }), + roll_kind::ROLL_START => match lyracore_shared::loot_roll::decode_start(&row.payload) { + Some((corpse_guid, slot, item_entry, countdown_ms, random_property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new(codec::build_loot_start_roll( + corpse_guid, + slot, + item_entry, + countdown_ms, + random_property_id, + ))), + ), + None => { + log::warn!( + "loot ROLL_START relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + roll_kind::ROLL_VOTE => match lyracore_shared::loot_roll::decode_vote(&row.payload) { + Some((corpse, slot, entry, number, vote, _auto_pass, property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new(codec::build_loot_roll( + corpse, + slot, + row.other_guid, + entry, + number, + vote, + property_id, + ))), + ), + None => { + log::warn!( + "loot ROLL_VOTE relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + roll_kind::ROLL_WON => match lyracore_shared::loot_roll::decode_won(&row.payload) { + Some((corpse, slot, entry, number, vote, property_id)) => Some( + ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new(codec::build_loot_roll_won( + corpse, + slot, + entry, + row.other_guid, + number, + vote, + property_id, + ))), + ), + None => { + log::warn!( + "loot ROLL_WON relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + roll_kind::MASTER_LIST => { + match lyracore_shared::loot_roll::decode_master_list(&row.payload) { + Some((_corpse_guid, eligible)) => Some(ServerOpcodeMessage::SMSG_LOOT_MASTER_LIST( + Box::new(codec::build_loot_master_list(&eligible)), + )), + None => { + log::warn!( + "loot MASTER_LIST relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + } + } + + roll_kind::MONEY_SHARE => { + match lyracore_shared::loot_roll::decode_money_share(&row.payload) { + Some(share) => Some(ServerOpcodeMessage::SMSG_LOOT_MONEY_NOTIFY( + codec::build_loot_money_notify(share), + )), + None => { + log::warn!( + "loot MONEY_SHARE relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + } + } + // Quest sharing: an eligible party member receives the shared quest — + // `row.other_guid` is the SHARER (resolved/pushed by `group::push_event`), `row.payload` + // is the quest entry. Opens the DETAILS screen with the SHARER as "giver" (the + // recipient's own `CMSG_QUESTGIVER_ACCEPT_QUEST` then re-validates fresh via the + // module's `GiverKind::Party` — this relay never authorizes anything by itself). + quest_share_kind::QUEST_SHARE => match row.payload.parse::() { + Ok(quest_id) => match store.quest_detail_view(quest_id) { + Ok(Some(detail)) => { + let (opcode, body) = codec::build_quest_details_raw(row.other_guid, &detail); + return vec![Outbound::Raw { opcode, body }]; + } + Ok(None) => { + log::warn!( + "quest QUEST_SHARE relay: quest {quest_id} not loaded (event {})", + row.id + ); + None + } + Err(e) => { + log::warn!( + "quest QUEST_SHARE relay: quest_detail lookup failed (event {}): {e}", + row.id + ); + None + } + }, + Err(_) => { + log::warn!( + "quest QUEST_SHARE relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + // Quest sharing: the SENDER's per-member feedback line, one row per member, + // always (whether or not the share actually landed) — `row.other_guid` is that member, + // `row.payload` is the `share_result` wire byte (mirrors gtker's `QuestPartyMessage` 1:1). + quest_share_kind::QUEST_PUSH_RESULT => match row.payload.parse::() { + Ok(code) => Some(ServerOpcodeMessage::MSG_QUEST_PUSH_RESULT(Box::new( + codec::build_quest_push_result(row.other_guid, code), + ))), + Err(_) => { + log::warn!( + "quest QUEST_PUSH_RESULT relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + }, + group_kind::READY_CHECK => Some(ServerOpcodeMessage::MSG_RAID_READY_CHECK(Box::new( + codec::build_ready_check(), + ))), + group_kind::READY_CHECK_ANSWER => broadcast_packet( + row, + lyracore_shared::group::decode_ready_check_answer(&row.payload), + |state| { + ServerOpcodeMessage::MSG_RAID_READY_CHECK(Box::new( + codec::build_ready_check_answer(row.other_guid, state), + )) + }, + ), + group_kind::TARGET_ICON_UPDATE => broadcast_packet( + row, + lyracore_shared::group::TargetIcon::decode(&row.payload) + .and_then(codec::build_target_icon_update), + |update| ServerOpcodeMessage::MSG_RAID_TARGET_UPDATE(Box::new(update)), + ), + group_kind::TARGET_ICON_LIST => { + match lyracore_shared::group::decode_target_icons(&row.payload) { + Some(icons) => { + let (opcode, body) = codec::build_target_icon_list_raw(&icons); + return vec![Outbound::Raw { opcode, body }]; + } + None => { + log_unparseable_broadcast(row); + None + } + } + } + group_kind::MINIMAP_PING => broadcast_packet( + row, + lyracore_shared::group::decode_minimap_ping(&row.payload), + |(x, y)| { + ServerOpcodeMessage::MSG_MINIMAP_PING(Box::new(codec::build_minimap_ping( + row.other_guid, + x, + y, + ))) + }, + ), + group_kind::RANDOM_ROLL => broadcast_packet( + row, + lyracore_shared::group::decode_random_roll(&row.payload), + |(min, max, result)| { + ServerOpcodeMessage::MSG_RANDOM_ROLL(Box::new(codec::build_random_roll( + row.other_guid, + min, + max, + result, + ))) + }, + ), + stone_kind::QUEUE => { + let packet = lyracore_shared::meeting_stone::decode_queue(&row.payload) + .and_then(|(area_id, status)| codec::build_meetingstone_setqueue(area_id, status)); + if packet.is_none() { + log::warn!( + "meeting stone QUEUE relay: unencodable payload {:?} (event {})", + row.payload, + row.id + ); + } + packet.map(ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE) + } + stone_kind::MEMBER_ADDED => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_MEMBER_ADDED( + codec::build_meetingstone_member_added(row.other_guid), + )), + stone_kind::IN_PROGRESS => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_IN_PROGRESS), + stone_kind::COMPLETE => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_COMPLETE), + other => { + log::warn!("group event relay: unknown kind {other} (id {})", row.id); + None + } + }; + match msg { + Some(m) => vec![Outbound::One(m)], + None => Vec::new(), + } +} + +/// The Character an INVITE, DECLINE or SET_LEADER row names by `other_guid`: the inviter, the +/// decliner, or the new leader. The Gateway always looks it up from the World Shard caches, +/// because Realm-core holds no `game_character` rows to resolve one from. `None` means no +/// connected shard can name it, or the lookup itself failed. +/// +/// INVITE and DECLINE fall back to an empty name and send the packet anyway, the way +/// [`crate::world::party::render_list`] fills an unresolved roster member's name with +/// `.unwrap_or_default()`: the popup is the target's only way to learn of an invite that already +/// committed durably, so dropping the packet would strand it for the whole GC window. SET_LEADER +/// skips the packet instead: the roster mirror the Gateway already pushed carries the new leader, +/// so the line is redundant rather than load-bearing. +pub(crate) fn other_character_name( + store: &St, + row: &GroupEvent, + relay: &str, +) -> Option { + match crate::world::presence::character_anywhere(store, row.other_guid) { + Ok(Some(character)) => Some(character.name), + Ok(None) => { + log::warn!( + "group {relay} relay: no shard names {} (event {})", + row.other_guid, + row.id + ); + None + } + Err(e) => { + log::warn!( + "group {relay} relay: name lookup for {} failed (event {}): {e:#}", + row.other_guid, + row.id + ); + None + } + } +} + +/// One Group Broadcast packet from its decoded payload. A payload that does not decode logs and +/// sends nothing, so a corrupt row never reaches the client. +pub(crate) fn broadcast_packet( + row: &GroupEvent, + decoded: Option, + packet: impl FnOnce(T) -> ServerOpcodeMessage, +) -> Option { + if decoded.is_none() { + log_unparseable_broadcast(row); + } + decoded.map(packet) +} + +pub(crate) fn log_unparseable_broadcast(row: &GroupEvent) { + log::warn!( + "group broadcast relay: unparseable kind {} payload {:?} (event {})", + row.kind, + row.payload, + row.id + ); +} + +pub(crate) fn private_recipient_audience(row_recipient_guid: u64, viewer_guid: u64) -> bool { + // 0 is "unaddressed"/"uninitialized", never a real character — an equality alone would let an + // unaddressed row match a half-initialized viewer (0 == 0), so zero denies on either side. + row_recipient_guid != 0 && row_recipient_guid == viewer_guid +} + +/// A Realm Chat Line: one `SMSG_MESSAGECHAT` for one recipient. The Relay already chose the +/// audience; an unknown Chat Kind sends nothing. +pub(crate) fn realm_chat_outbound(row: &RealmChatEvent) -> Vec { + codec::build_realm_chat_line( + row.kind, + row.speaker_guid, + row.language, + row.chat_tag, + row.channel_name.clone(), + row.message.clone(), + ) + .map(|message| Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(message)))) + .into_iter() + .collect() +} + +/// Build a Package System Message after the caller validates the recipient. +pub(crate) fn system_message_event_outbound(row: &SystemMessageEvent) -> Vec { + let message = codec::build_gm_system_message(row.message.clone()); + vec![Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(message), + ))] +} + +/// Nearby chat: the one body both legs run. A player speaker always hears +/// their own line; everyone else is `chat_in_range`-gated (say/`/e`/text emote ~25yd, yell ~300yd, +/// map + instance fenced), with both endpoints read from the COORDINATOR's global cache — the +/// AOI-scoped per-player cache could not see a 100–300yd YELL speaker. Missing endpoint → drop +/// (safer than flooding). `/e` (EMOTE) also gates on [`lyracore_shared::faction::same_team`] — the +/// one Chat Kind here vanilla keeps same-team-only by default; say and yell still reach both teams. +/// A player's line carries the chat tag of its live entity's Away Status, read when the line is +/// relayed (cm:Player.cpp:16571-16599). A creature's line carries none. +pub(crate) fn chat_event_outbound( + coord: &Coordinator, + self_guid: u64, + row: &ChatEvent, +) -> Vec { + let guard = coord.0.coord(); + let entities = guard.conn.db.game_world_entity(); + let speaker = entities.guid().find(&row.sender_guid); + if row.sender_guid != self_guid { + let Some(speaker) = &speaker else { + return Vec::new(); + }; + let Some(listener) = entities.guid().find(&self_guid) else { + return Vec::new(); + }; + let range_yd = chat_range_yd(row.chat_type); + if !chat_in_range( + speaker.map_id, + speaker.instance_id, + speaker.x, + speaker.y, + listener.map_id, + listener.instance_id, + listener.x, + listener.y, + range_yd * range_yd, + ) { + return Vec::new(); + } + if row.chat_type == lyracore_shared::chat::broadcast_chat::EMOTE + && !lyracore_shared::faction::same_team( + (speaker.unit_bytes_0 & 0xFF) as u8, + (listener.unit_bytes_0 & 0xFF) as u8, + ) + { + return Vec::new(); + } + } + let sender_name = if matches!(row.sender_guid >> 48, 0xF130 | 0xF140) { + let Some(speaker) = &speaker else { + return Vec::new(); + }; + let Some(template) = guard + .conn + .db + .game_creature_template() + .entry() + .find(&speaker.entry) + else { + return Vec::new(); + }; + Some(template.name) + } else { + None + }; + vec![chat_event_message( + row, + sender_name, + nearby_chat_tag(speaker.as_ref()), + )] +} + +/// The chat tag a nearby line carries: a player speaker's Away Status, and none for a creature or +/// a speaker whose entity is already gone. +pub(crate) fn nearby_chat_tag(speaker: Option<&WorldEntity>) -> u8 { + match speaker { + Some(entity) + if entity.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 => + { + lyracore_shared::chat::chat_tag_for(entity.player_flags) + } + _ => lyracore_shared::chat::chat_tag::NONE, + } +} + +pub(crate) fn chat_event_message( + row: &ChatEvent, + sender_name: Option, + chat_tag: u8, +) -> Outbound { + let message = codec::build_chat_message_to( + row.sender_guid, + sender_name, + row.target_guid, + row.chat_type, + row.language, + row.message.clone(), + chat_tag, + ); + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(message))) +} + +/// Text emotes (`/wave`, `/dance`, …): SMSG_TEXT_EMOTE + SMSG_EMOTE, `chat_in_range`-gated at +/// [`lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD`] like say (cm:ChatHandler.cpp:789- +/// 794 sends to both teams, so there is no team gate here — contrast `chat_event_outbound`'s EMOTE +/// arm). The performer always sees their own emote. The target name resolves through the +/// coordinator cache (player, else creature template); unknown ids degrade gracefully so the rest +/// still relays. +pub(crate) fn emote_event_outbound( + coord: &Coordinator, + self_guid: u64, + row: &EmoteEvent, +) -> Vec { + if row.sender_guid != self_guid { + let guard = coord.0.coord(); + let speaker = match guard + .conn + .db + .game_world_entity() + .guid() + .find(&row.sender_guid) + { + Some(e) => e, + None => return Vec::new(), + }; + let listener = match guard.conn.db.game_world_entity().guid().find(&self_guid) { + Some(e) => e, + None => return Vec::new(), + }; + let range_yd = lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD; + if !chat_in_range( + speaker.map_id, + speaker.instance_id, + speaker.x, + speaker.y, + listener.map_id, + listener.instance_id, + listener.x, + listener.y, + range_yd * range_yd, + ) { + return Vec::new(); + } + } + // target_guid == 0 → untargeted emote (the client sends 0 when nothing is selected). + let target_name = if row.target_guid != 0 { + let guard = coord.0.coord(); + let db = &guard.conn.db; + db.game_character() + .guid() + .find(&row.target_guid) + .map(|c| c.name) + .or_else(|| { + db.game_world_entity() + .guid() + .find(&row.target_guid) + .and_then(|e| { + db.game_creature_template() + .entry() + .find(&e.entry) + .map(|c| c.name) + }) + }) + } else { + None + }; + let mut out = Vec::new(); + if let Some(m) = + codec::build_text_emote(row.sender_guid, row.text_emote, row.emote_anim, target_name) + { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_TEXT_EMOTE( + Box::new(m), + ))); + } + if let Some(a) = codec::build_emote_anim(row.sender_guid, row.emote_anim) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_EMOTE(Box::new(a)))); + } + out +} + +/// The SAY/YELL proximity gate: does a speaker at `(speaker_map, speaker_instance, +/// speaker_x, speaker_y)` reach a listener at `(listener_map, listener_instance, listener_x, +/// listener_y)` within `range_sq` (already squared — pass `SAY_RANGE_SQ`/`YELL_RANGE_SQ`)? Different +/// maps or different instances never hear each other (also guards the distance arithmetic against +/// comparing positions on unrelated maps). Pure — no I/O — so it is the ONE tested source of truth +/// for `on_chat`'s range gate. +#[allow(clippy::too_many_arguments)] +pub(crate) fn chat_in_range( + speaker_map: u32, + speaker_instance: u64, + speaker_x: f32, + speaker_y: f32, + listener_map: u32, + listener_instance: u64, + listener_x: f32, + listener_y: f32, + range_sq: f32, +) -> bool { + if speaker_map != listener_map || speaker_instance != listener_instance { + return false; + } + let dx = speaker_x - listener_x; + let dy = speaker_y - listener_y; + dx * dx + dy * dy <= range_sq +} + +/// Show an Instance Removal countdown to its owner on the callback's Shard. +pub(crate) fn instance_removal_started( + view: &Arc, + shard: ShardId, + row: &InstanceRemoval, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (view, due) = (view.clone(), row.scheduled_at); + enqueue(viewer, move |viewer| { + let now = crate::codec::movement::subscriptions::unix_now_micros(); + instance_removal_time_left_ms(&due, now) + .map(|left| show_countdown(&view, viewer.self_guid, left)) + .into_iter() + .collect() + }); +} + +/// Hide an ended Instance Removal countdown, canceled or expired, from its owner on the callback's +/// Shard. +pub(crate) fn instance_removal_ended(view: &Arc, shard: ShardId, row: &InstanceRemoval) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let view = view.clone(); + enqueue(viewer, move |viewer| { + vec![show_countdown(&view, viewer.self_guid, 0)] + }); +} + +/// `SMSG_RAID_GROUP_ONLY` with `timer_ms` left, 0 to hide the countdown, noting what the client +/// now shows. +pub(crate) fn show_countdown(view: &WorldView, character_guid: u64, timer_ms: u32) -> Outbound { + view.note_countdown(character_guid, timer_ms > 0); + Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY( + crate::codec::build_raid_group_only(timer_ms), + )) +} + +/// The timer a world entry sends: the time left of the countdown `due` on the Home Shard, or 0 to +/// hide a countdown the client was last shown when the Home Shard holds none. A Character that +/// left the Instance Pool through the portal reaches its new Home Shard this way (vmangos hides +/// with the same zero timer, vm:Player.cpp:18561-18569). `None` sends nothing. Pure. +pub(crate) fn resident_countdown_ms( + due: Option<&spacetimedb_sdk::ScheduleAt>, + shown: bool, + now_micros: u64, +) -> Option { + match due { + Some(due) => instance_removal_time_left_ms(due, now_micros), + None => shown.then_some(0), + } +} + +/// Milliseconds left before `due` at `now_micros`, rounded up and never more than the full +/// countdown, so a Gateway clock behind the Module's never shows extra time. `None` once due. Pure. +pub(crate) fn instance_removal_time_left_ms( + due: &spacetimedb_sdk::ScheduleAt, + now_micros: u64, +) -> Option { + let spacetimedb_sdk::ScheduleAt::Time(at) = due else { + return None; + }; + let due_micros = u64::try_from(at.to_micros_since_unix_epoch()).ok()?; + let left_micros = due_micros + .checked_sub(now_micros) + .filter(|left| *left > 0)?; + let left_ms = left_micros.div_ceil(1_000); + Some( + u32::try_from(left_ms).map_or(lyracore_shared::instance::INSTANCE_REMOVAL_MS, |left| { + left.min(lyracore_shared::instance::INSTANCE_REMOVAL_MS) + }), + ) +} + +/// The world-entry Instance Removal packet for `character_guid`, read from its Home Shard `coord`: +/// a reconnect inside the countdown shows the time left, and an arrival without one hides a +/// countdown still on screen. See [`resident_countdown_ms`]. +pub(crate) fn resident_instance_removal_outbound( + view: &WorldView, + coord: &Coordinator, + character_guid: u64, +) -> Vec { + let due = coord + .0 + .coord() + .conn + .db + .game_instance_removal() + .character_guid() + .find(&character_guid) + .map(|row| row.scheduled_at); + let now = crate::codec::movement::subscriptions::unix_now_micros(); + resident_countdown_ms(due.as_ref(), view.countdown_shown(character_guid), now) + .map(|timer_ms| show_countdown(view, character_guid, timer_ms)) + .into_iter() + .collect() +} + +/// A Realm Chat Line landed → one `SMSG_MESSAGECHAT` per recipient with a World Session on this +/// Gateway, whatever Shard it plays on. The Module chose the audience. The only per-listener +/// filter here is the listener's own ignore list, and only for lines the Module marked ignorable. +pub(crate) fn realm_chat_appeared(view: &WorldView, row: &RealmChatEvent) { + let row = Arc::new(row.clone()); + for &recipient in &row.recipients { + let Some(viewer) = view + .session_of_owner(recipient) + .and_then(|session| view.viewer(session)) + else { + continue; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + recipient, + viewer.self_guid, + ) { + continue; + } + if row.ignorable && viewer.ignores(row.speaker_guid) { + continue; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::social::subscriptions::realm_chat_outbound(&row) + }); + } +} + +/// A contact row changed on `shard` → the owner's ignore set or friend set, whichever list the row +/// names, if the owner plays on that Shard. +pub(crate) fn contact_changed(view: &WorldView, shard: ShardId, row: &ContactEntry, present: bool) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { + return; + }; + if row.is_ignore { + viewer.set_ignored(row.target_guid, present); + } else { + viewer.set_friend(row.target_guid, present); + } +} + +/// Rebuild the ignore set AND the friend set of every viewer on `shard` from that Shard's contact +/// rows. A resubscribe replays inserts but never the deletes it missed, so the live callbacks alone +/// could keep a removed contact in force until relog. Runs on the Shard's pump, like its contact +/// callbacks. +pub(crate) fn replace_contact_sets(view: &WorldView, shard: ShardId, contacts: &[ContactEntry]) { + let mut ignored: HashMap> = HashMap::new(); + let mut friends: HashMap> = HashMap::new(); + for row in contacts { + let by_owner = if row.is_ignore { + &mut ignored + } else { + &mut friends + }; + by_owner + .entry(row.owner_guid) + .or_default() + .insert(row.target_guid); + } + for viewer in view.viewers_on_shard(shard) { + *viewer.ignored.lock().unwrap_or_else(|p| p.into_inner()) = + ignored.get(&viewer.self_guid).cloned().unwrap_or_default(); + *viewer.friends.lock().unwrap_or_else(|p| p.into_inner()) = + friends.get(&viewer.self_guid).cloned().unwrap_or_default(); + } +} + +/// The World Sessions that could possibly hear a friend online/offline notice about +/// `subject_guid`, on any Shard: every viewer that lists it as a friend, minus its own viewer. +/// Team is checked separately by each caller — one already knows it for free (the just-committed +/// `game_character` row), the other only after a read. +pub(crate) fn friend_notice_candidates(view: &WorldView, subject_guid: u64) -> Vec> { + view.all_viewers() + .into_iter() + .filter(|viewer| viewer.self_guid != subject_guid && viewer.is_friend(subject_guid)) + .collect() +} + +pub(crate) fn character_home_changed( + view: &WorldView, + shard: ShardId, + old: &Character, + new: &Character, +) { + let home = ( + new.home_x, + new.home_y, + new.home_z, + new.home_map, + new.home_zone, + ); + if home + == ( + old.home_x, + old.home_y, + old.home_z, + old.home_map, + old.home_zone, + ) + { + return; + } + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(new.guid)) else { + return; + }; + enqueue(viewer, move |_| { + let mut body = Vec::with_capacity(20); + for coordinate in [home.0, home.1, home.2] { + body.extend_from_slice(&coordinate.to_le_bytes()); + } + body.extend_from_slice(&home.3.to_le_bytes()); + body.extend_from_slice(&home.4.to_le_bytes()); + vec![Outbound::Raw { + opcode: 0x0155, + body, + }] + }); +} + +/// A Character logged in on this Shard → `FRIEND_ONLINE` to every same-team viewer, on any Shard, +/// who lists it as a friend (cm:CharacterHandler.cpp:856, cm:WorldSession.cpp:754, +/// cm:SocialMgr.cpp:263-292). +/// +/// Hooked to `game_character` (shard-local, inside `player_login`'s own transaction), never the +/// realm-wide Account Claim: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` BEFORE +/// `enter_world`/`player_login`, so a Claim-driven edge could fire for a Character that has not +/// entered the world yet, or never will if `enter_world` then refuses — carrying its stale +/// pre-login level and zone either way. This row IS the just-committed login state, so no further +/// read is needed: race/class/level/zone come straight off it, and Away Status is always `None` +/// (login clears it — nobody crosses a loading screen AFK). +/// +/// The edge is landing on a fresh, nonzero `session_start_micros`, not `online` flipping false → +/// true. `player_login` is the only writer that ever stamps `session_start_micros` to a real value; +/// every `persist_entity` call — a real logout AND a same-database transfer's `freeze_live_entity` +/// alike — zeroes it back out, but `freeze_live_entity` passes `set_offline: false` and so never +/// touches `online`. An abandoned, rolled-back transfer therefore leaves a Character `online: true` +/// with no live entity; a plain `old.online` check would never fire for that Character's real next +/// login, because `online` reads true on both sides of it. Marks the Character in this Gateway's +/// own `online_characters` set, the dedup [`account_claim_changed`] reads before it answers +/// FRIEND_OFFLINE for the same Character's later claim close. +pub(crate) fn character_online_changed(view: &WorldView, old: &Character, new: &Character) { + if new.session_start_micros == 0 || old.session_start_micros == new.session_start_micros { + return; + } + debug_assert!( + new.online, + "player_login always sets online alongside session_start_micros" + ); + view.mark_character_online(new.guid); + let team = lyracore_shared::faction::team_for_race(new.race); + let online_fields = crate::codec::FriendOnline { + away: crate::world::presence::AwayStatus::None, + zone_id: new.zone_id, + level: new.level, + class: new.class, + }; + let subject_guid = new.guid; + for viewer in friend_notice_candidates(view, subject_guid) { + if viewer.team != team { + continue; + } + let online_fields = online_fields.clone(); + enqueue(viewer, move |_| { + let (opcode, body) = crate::codec::build_friend_status_raw( + wow_world_base::shared::friend_result_vanilla_tbc::FriendResult::Online, + subject_guid, + Some(online_fields), + ); + vec![Outbound::Raw { opcode, body }] + }); + } +} + +/// Does an Account Claim row's old and new state close the OLD row's Character? `old` is `None` +/// for the account's first-ever claim, never a close. Reopening after a close, and a fresh open, +/// both answer `None` — that edge is [`character_online_changed`]'s, not this file's. +pub(crate) fn claim_closed(old: Option<&AccountClaim>, new: &AccountClaim) -> Option { + let old = old?; + let replaced = old.generation != new.generation || old.character_guid != new.character_guid; + let was_live = !old.closed; + (was_live && (replaced || new.closed)).then_some(old.character_guid) +} + +/// The departing Character's team for one Account Claim close — read with the local-cache-only +/// [`presence::character_identity_anywhere`], not the health-checked [`presence::of`]: the +/// Character has just stopped being reachable, so a Whereabouts read has nothing left to prove. +/// Computed ONCE per transition, by whichever candidate's job runs first (cached in the caller's +/// `OnceLock` for every other candidate), on that job's own writer thread, NEVER the shared pump: +/// this read can cross the network to another Shard, and the pump is the one thread every World +/// Session on this Shard depends on. `None` when no connected Shard currently knows the Character +/// at all: never guess a team, which could notify the wrong side. +pub(crate) fn claim_close_team(coord: &Coordinator, subject_guid: u64) -> Option { + let identity = crate::world::presence::character_identity_anywhere(coord, subject_guid) + .ok() + .flatten()?; + Some(lyracore_shared::faction::team_for_race(identity.race)) +} + +/// An Account Claim closed on Realm-core → `FRIEND_OFFLINE` to every same-team viewer, on any +/// Shard, who lists the Character as a friend (cm:CharacterHandler.cpp:856, +/// cm:WorldSession.cpp:754, cm:SocialMgr.cpp:263-292) — but ONLY for a Character this Gateway +/// itself watched come online ([`WorldView::take_character_online`]). A claim can close without +/// `player_login` ever having run for it: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` +/// BEFORE `enter_world`, so a login that fails after the claim opens releases it on session +/// teardown with no Character having ever entered the world, and this must answer nothing for it. +/// `coord` is the world handle [`claim_close_team`] reads through — never the Realm-core handle, +/// which holds no Characters. +pub(crate) fn account_claim_changed( + view: &WorldView, + coord: &Coordinator, + old: Option<&AccountClaim>, + new: &AccountClaim, +) { + let Some(subject_guid) = claim_closed(old, new) else { + return; + }; + if !view.take_character_online(subject_guid) { + return; + } + let candidates = friend_notice_candidates(view, subject_guid); + if candidates.is_empty() { + return; + } + let team: Arc>> = Arc::new(OnceLock::new()); + for viewer in candidates { + let (coord, team) = (coord.clone(), team.clone()); + let viewer_team = viewer.team; + enqueue(viewer, move |_| { + let Some(subject_team) = *team.get_or_init(|| claim_close_team(&coord, subject_guid)) + else { + return Vec::new(); + }; + if subject_team != viewer_team { + return Vec::new(); + } + let (opcode, body) = crate::codec::build_friend_status_raw( + wow_world_base::shared::friend_result_vanilla_tbc::FriendResult::Offline, + subject_guid, + None, + ); + vec![Outbound::Raw { opcode, body }] + }); + } +} + +/// Queue a Package System Message for its addressed World Session. +pub(crate) fn system_message_appeared(view: &WorldView, row: &SystemMessageEvent) { + let Some(session) = view.session_of_owner(row.recipient_guid) else { + return; + }; + let Some(viewer) = view.viewer(session) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) { + return; + } + let row = row.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::social::subscriptions::system_message_event_outbound(&row) + }); +} + +/// A group/loot-roll/quest-share event landed → the kind-decoded packets to the row's RECIPIENT +/// and nobody else (`coord` feeds the QUEST_SHARE detail JOIN inside the job). The recipient gets +/// its rows of one transaction in the order the Module inserted them ([`GroupEventCursor::take`]). +pub(crate) fn group_event_appeared( + view: &WorldView, + coord: &Coordinator, + source: GroupEventSource, + row: &GroupEvent, + snapshot: &mut GroupEventSnapshot, + read_table: impl FnOnce() -> Vec, +) { + let Some(session) = view.session_of_owner(row.recipient_guid) else { + return; + }; + let Some(viewer) = view.viewer(session) else { + return; + }; + if !crate::codec::social::subscriptions::private_recipient_audience( + row.recipient_guid, + viewer.self_guid, + ) { + return; + } + let rows = snapshot.take(&viewer.group_events, source, row, read_table); + if rows.is_empty() { + return; + } + let coord = coord.clone(); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |viewer| { + let mut packets = Vec::new(); + for row in &rows { + packets.extend(crate::codec::social::subscriptions::group_event_outbound( + &coord, self_guid, row, + )); + // A party frame sets every member's online flag, so Member Stats start over behind it. + if row.kind == lyracore_shared::group::event_kind::LIST { + viewer.member_stats.forget_all(); + } + } + packets + }); +} + +/// A shard mirror delta is the point at which viewer-relative Loot Tag flags can be projected from +/// current membership without racing the realm event that requested the mirror write. +pub(crate) fn group_member_mirror_changed( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &GroupMember, +) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let coord = coord.clone(); + enqueue(viewer.clone(), move |viewer| { + crate::codec::entity::subscriptions::loot_tag_flags_after_membership_change(&coord, &viewer) + }); +} + +/// A text emote names no cell of its own; the row carries the actor's cell (stamped at insert +/// time), widened to the 25 yd text-emote range rather than the AOI box — the job's `chat_in_range` +/// gate is the exact filter, this only bounds the candidate set. +pub(crate) fn emote_audience( + view: &WorldView, + shard: ShardId, + row: &EmoteEvent, +) -> Vec> { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + view.cell_audience( + shard, + Some(key), + cells_within(lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD), + &[row.sender_guid, row.target_guid], + ) +} + +/// A text emote landed → SMSG_TEXT_EMOTE + SMSG_EMOTE per viewer within 25 yd of the sender +/// (`emote_event_outbound`'s `chat_in_range` gate). Name resolve runs in the job against `coord`'s +/// cache. +pub(crate) fn emote_appeared( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &EmoteEvent, +) { + let row = Arc::new(row.clone()); + for viewer in emote_audience(view, shard, &row) { + let (row, coord) = (row.clone(), coord.clone()); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |_| { + crate::codec::social::subscriptions::emote_event_outbound(&coord, self_guid, &row) + }); + } +} + +/// A chat line names its speaker and nothing else; the speaker's indexed cell anchors it, widened +/// to the kind's yard range (a yell carries 300 yd, four cells past the box). +pub(crate) fn chat_audience(view: &WorldView, shard: ShardId, row: &ChatEvent) -> Vec> { + let key = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, row.sender_guid, shard); + let span = cells_within(crate::codec::social::subscriptions::chat_range_yd( + row.chat_type, + )); + view.cell_audience(shard, key, span, &[row.sender_guid, row.target_guid]) +} + +/// A say/yell line landed → SMSG_MESSAGECHAT per viewer, `chat_in_range`-gated inside the job +/// (`chat_event_outbound`: speaker echo, SAY/YELL radii, map+instance fence, coordinator-cache +/// endpoint lookups). +pub(crate) fn chat_appeared( + view: &WorldView, + coord: &Coordinator, + shard: ShardId, + row: &ChatEvent, +) { + let row = Arc::new(row.clone()); + for viewer in chat_audience(view, shard, &row) { + let (row, coord) = (row.clone(), coord.clone()); + let self_guid = viewer.self_guid; + enqueue(viewer.clone(), move |_| { + crate::codec::social::subscriptions::chat_event_outbound(&coord, self_guid, &row) + }); + } +} + +crate::stdb::relays::declare_relays! { + game_instance_removal [false] { + insert(Snapshot) => |context, _event, row| instance_removal_started(&context.view, context.shard, row); + delete(Snapshot) => |context, _event, row| instance_removal_ended(&context.view, context.shard, row); + } + game_system_message_event [false] { + insert(Live) => |context, _event, row| system_message_appeared(&context.view, row); + } + game_realm_chat_event [true] { + insert(Live) => |context, _event, row| realm_chat_appeared(&context.view, row); + } + game_character_contact [false] { + insert(Snapshot) => |context, _event, row| contact_changed(&context.view, context.shard, row, true); + delete(Snapshot) => |context, _event, row| contact_changed(&context.view, context.shard, row, false); + } + game_account_claim [true] { + update(Snapshot) => |context, _event, old, new| account_claim_changed(&context.view, &context.coord, Some(old), new); + } + game_character [false] { + update(Snapshot) => |context, _event, old, new| { + character_home_changed(&context.view, context.shard, old, new); + character_online_changed(&context.view, old, new); + }; + } + game_group_member [false] { + insert(Snapshot) => |context, _event, row| group_member_mirror_changed(&context.view, &context.coord, context.shard, row); + delete(Snapshot) => |context, _event, row| group_member_mirror_changed(&context.view, &context.coord, context.shard, row); + } + game_emote_event [false] { + insert(Snapshot) => |context, _event, row| emote_appeared(&context.view, &context.coord, context.shard, row); + } + game_chat_event [false] { + insert(Snapshot) => |context, _event, row| chat_appeared(&context.view, &context.coord, context.shard, row); + } + game_group_event [true] { + insert(Live) => group_event_callback(); + } +} + +fn group_event_callback( +) -> impl FnMut(&crate::stdb::relays::RelayContext, &EventContext, &GroupEvent) + Send { + let mut snapshot = GroupEventSnapshot::default(); + move |context, event, row| { + group_event_appeared( + &context.view, + &context.coord, + context.source, + row, + &mut snapshot, + || event.db.game_group_event().iter().collect(), + ); + } +} diff --git a/gateway/src/codec/taxi.rs b/gateway/src/codec/taxi.rs index 64246ebd7..b85f1a544 100644 --- a/gateway/src/codec/taxi.rs +++ b/gateway/src/codec/taxi.rs @@ -209,3 +209,5 @@ mod tests { } } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/taxi/subscriptions.rs b/gateway/src/codec/taxi/subscriptions.rs new file mode 100644 index 000000000..0f47d7c31 --- /dev/null +++ b/gateway/src/codec/taxi/subscriptions.rs @@ -0,0 +1,109 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::{CellKey, ShardId}; +use crate::stdb::world_view::*; +use crate::stdb::Coordinator; +use crate::world::Outbound; +use lyracore_shared::spatial::BOX_HALF_SPAN; +use std::collections::HashSet; +use std::sync::Mutex; +use wow_world_messages::vanilla::Vector3d; + +/// One module-owned passenger route to self and observers. Ordinary player motion deliberately +/// excludes self; taxi presentation must include it because the client is not driving the mover. +pub(crate) fn taxi_spline_outbound( + created: &Mutex>, + self_guid: u64, + row: &TaxiPassengerSpline, +) -> Vec { + if row.character_guid != self_guid && !created.lock().unwrap().contains(&row.character_guid) { + return Vec::new(); + } + let points: Vec = row + .points + .chunks_exact(3) + .map(|point| Vector3d { + x: point[0], + y: point[1], + z: point[2], + }) + .collect(); + if points.is_empty() || points.len() * 3 != row.points.len() { + log::warn!( + "taxi spline: malformed route ({} coordinates) for passenger {}", + row.points.len(), + row.character_guid + ); + return Vec::new(); + } + let start = Vector3d { + x: row.start_x, + y: row.start_y, + z: row.start_z, + }; + codec::build_taxi_move_raw( + row.character_guid, + start, + points, + row.duration_ms, + row.spline_id, + ) + .map_or_else(Vec::new, |(opcode, body)| { + vec![Outbound::Raw { opcode, body }] + }) +} + +pub(crate) fn append_resident_taxi_after_create( + created_outbound: &mut Vec, + created: &Mutex>, + self_guid: u64, + row: &TaxiPassengerSpline, +) { + created_outbound.extend(taxi_spline_outbound(created, self_guid, row)); +} + +/// Replay a resident passenger spline after the owner's self CREATE/login batch. The same helper +/// is used by reconnect recovery, keeping the CREATE-before-MONSTER_MOVE ordering explicit. +pub(crate) fn resident_taxi_spline_outbound( + coord: &Coordinator, + viewer: &Viewer, + character_guid: u64, +) -> Vec { + let guard = coord.0.coord(); + let Some(row) = guard + .conn + .db + .game_taxi_passenger_spline() + .character_guid() + .find(&character_guid) + else { + return Vec::new(); + }; + taxi_spline_outbound(&viewer.created, viewer.self_guid, &row) +} + +/// A passenger spline is always sent to its owner. Nearby sessions receive it only after the +/// outbound visibility check confirms that the passenger already exists in their client world. +pub(crate) fn taxi_spline(view: &WorldView, shard: ShardId, row: &TaxiPassengerSpline) { + let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); + let row = row.clone(); + for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[row.character_guid]) { + let row = row.clone(); + enqueue(viewer.clone(), move |viewer| { + crate::codec::taxi::subscriptions::taxi_spline_outbound( + &viewer.created, + viewer.self_guid, + &row, + ) + }); + } +} + +crate::stdb::relays::declare_relays! { + game_taxi_passenger_spline [false] { + insert(Snapshot) => |context, _event, row| taxi_spline(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| taxi_spline(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/trainer.rs b/gateway/src/codec/trainer.rs index a13052fc5..2594eb400 100644 --- a/gateway/src/codec/trainer.rs +++ b/gateway/src/codec/trainer.rs @@ -44,12 +44,11 @@ pub fn build_trainer_list( let spells = spells .iter() .map(|s| { - let state = if s.known { - TrainerSpellState::Gray - } else if s.player_level < s.required_level as u32 { - TrainerSpellState::Red - } else { - TrainerSpellState::Green + use lyracore_shared::trainer::{offering_state, OfferingState}; + let state = match offering_state(s.known, s.player_level, u32::from(s.required_level)) { + OfferingState::Known => TrainerSpellState::Gray, + OfferingState::LevelTooLow => TrainerSpellState::Red, + OfferingState::Available => TrainerSpellState::Green, }; TrainerSpell { spell: s.spell_id, @@ -213,3 +212,5 @@ mod tests { assert_eq!(msg.error, TrainingFailureReason::NotEnoughMoney); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/trainer/subscriptions.rs b/gateway/src/codec/trainer/subscriptions.rs new file mode 100644 index 000000000..edc4900d6 --- /dev/null +++ b/gateway/src/codec/trainer/subscriptions.rs @@ -0,0 +1,55 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Skill pane: the shared-dispatch twin of the per-player skill relays +/// (insert and update ran the same body there too). Audience (owner only) resolved by the +/// owner-session lookup in `world_view::skill_changed`; the slot allocation writes the viewer's +/// OWN `skill_slots` — the same map the per-player leg captures, seeded from the login layout. +pub(crate) fn relay_skill( + viewer: &Viewer, + skill_line: u32, + current: u16, + max_rank: u16, +) -> Vec { + let mut guard = viewer.skill_slots.lock().unwrap(); + let (map, next_free) = &mut *guard; + let slot = *map.entry(skill_line).or_insert_with(|| { + let s = *next_free; + *next_free += 1; + s + }); + if let Ok(sk) = wow_world_messages::vanilla::Skill::try_from(skill_line) { + if let Some(m) = codec::build_skill_values(viewer.self_guid, slot, sk, current, max_rank) { + return vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(m), + ))]; + } + } + Vec::new() +} + +/// A skill row changed (line learned / skill-up). Self-only family, same owner-session-lookup +/// audience as [`rest_state_appeared`] — the slot allocation runs in the job on the owner's own +/// writer thread, against the viewer's `skill_slots` (the same map the per-player leg uses). +pub(crate) fn skill_changed(view: &WorldView, shard: ShardId, row: &PlayerSkill) { + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { + return; + }; + let (line, cur, max) = (row.skill_line, row.current, row.max_rank); + enqueue(viewer.clone(), move |viewer| { + crate::codec::trainer::subscriptions::relay_skill(&viewer, line, cur, max) + }); +} + +crate::stdb::relays::declare_relays! { + game_player_skill [false] { + insert(Snapshot) => |context, _event, row| skill_changed(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| skill_changed(&context.view, context.shard, row); + } +} diff --git a/gateway/src/codec/weather.rs b/gateway/src/codec/weather.rs index cc53af58a..7390d8506 100644 --- a/gateway/src/codec/weather.rs +++ b/gateway/src/codec/weather.rs @@ -178,3 +178,5 @@ mod tests { assert_eq!(msg.sound_id, 0); } } + +pub(crate) mod subscriptions; diff --git a/gateway/src/codec/weather/subscriptions.rs b/gateway/src/codec/weather/subscriptions.rs new file mode 100644 index 000000000..395affcf1 --- /dev/null +++ b/gateway/src/codec/weather/subscriptions.rs @@ -0,0 +1,123 @@ +//! Table Relays for this Protocol Family. + +use crate::codec::{self}; +use crate::stdb::bindings::*; +use crate::stdb::world_index::ShardId; +use crate::stdb::world_view::*; +use crate::world::Outbound; +use std::sync::atomic::Ordering; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +use wow_world_messages::vanilla::WeatherChangeType; + +/// Encode one zone's sky. The audience decision (the viewer's stored zone) already happened in +/// `world_view::weather_changed`, and the changed row carried the whole packet, so nothing is read +/// here. +pub(crate) fn weather_outbound( + view: codec::ZoneWeatherView, + change: WeatherChangeType, +) -> Vec { + vec![Outbound::One(ServerOpcodeMessage::SMSG_WEATHER(Box::new( + codec::build_weather(view, change), + )))] +} + +/// Read `zone_id`'s current sky off this shard's cache and encode it — the zone-entry leg, run on +/// the arriving viewer's own writer thread. Goes through the same `WeatherStore` seam world entry +/// uses, so a zone with no row means fine weather here too. +pub(crate) fn zone_weather_outbound( + store: &St, + zone_id: u32, + change: WeatherChangeType, +) -> Vec { + vec![Outbound::One(crate::world::zone_weather_message( + store, zone_id, change, + ))] +} + +/// A zone's sky changed → `SMSG_WEATHER` to every viewer standing in that zone on this shard, and +/// to nobody else. The zone comparison IS the audience: weather is per zone, so an Elwynn row can +/// never reach a Westfall viewer however close the two stand. +/// +/// Smooth, because this is a scheduled drift the client should fade in. The synchronizing sends +/// (world entry, zone entry) are Instant and come from [`zone_crossed`] and the login path. +/// +/// The row carries the whole packet, so no cache read happens in the job — which also means a +/// later row cannot overtake an earlier one's encoding and show a client the wrong sky. +pub(crate) fn weather_changed(view: &WorldView, shard: ShardId, row: &ZoneWeather) { + let zone_id = row.zone_id; + let view_of_row = crate::codec::ZoneWeatherView { + weather_type: row.weather_type, + intensity: row.intensity, + }; + for viewer in viewers_on_shard(view, shard) { + if viewer.zone_id.load(Ordering::Relaxed) != zone_id { + continue; + } + enqueue(viewer.clone(), move |_| { + crate::codec::weather::subscriptions::weather_outbound( + view_of_row, + WeatherChangeType::Smooth, + ) + }); + } +} + +/// The `game_zone_weather` relay leg, reachable from the world-session tests: the encrypted-wire +/// test drives the SAME body [`arm_shard`] wires to the table callback, so a live weather row and a +/// test row take one path. Test-only because a shard's own callback is the sole production caller. +#[cfg(test)] +pub(crate) fn relay_zone_weather(view: &WorldView, shard: ShardId, row: &ZoneWeather) { + weather_changed(view, shard, row); +} + +/// The self entity's own row says it is standing in a different zone than the viewer's routing +/// state remembers → move the routing state and send the destination zone's current weather ONCE, +/// Instant, so the stale source-zone sky is replaced immediately (story 7). +/// +/// This covers walking, a same-map teleport and a taxi landing — every path that changes the zone +/// without rebuilding the session. A cross-map path tears the viewer down and re-runs world entry, +/// which sends its own weather. +/// +/// `store` is this shard's coordinator, taken through the [`crate::world::WeatherStore`] seam +/// because the destination's sky is READ, on the arriving viewer's own writer thread, rather than +/// carried by the row that triggered this. +/// +/// The row-delta comparison comes FIRST because this sits on the pump, under the highest-rate +/// table in the module: every creature heartbeat and every player step that stays inside its zone +/// leaves here without touching the viewer registry. Creatures never carry a zone at all, so their +/// updates are all `0 -> 0`. +pub(crate) fn zone_crossed( + view: &WorldView, + store: &St, + shard: ShardId, + guid: u64, + old_zone: u32, + zone_id: u32, +) where + St: crate::world::WeatherStore + Clone + 'static, +{ + if zone_id == old_zone { + return; + } + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(guid)) else { + return; + }; + if !viewer.enter_zone(zone_id) { + return; + } + let store = store.clone(); + enqueue(viewer.clone(), move |_| { + crate::codec::weather::subscriptions::zone_weather_outbound( + &store, + zone_id, + WeatherChangeType::Instant, + ) + }); +} + +crate::stdb::relays::declare_relays! { + game_zone_weather [false] { + insert(Snapshot) => |context, _event, row| weather_changed(&context.view, context.shard, row); + update(Snapshot) => |context, _event, _old, row| weather_changed(&context.view, context.shard, row); + } +} diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 0a40bd44e..18dc6c995 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -1,62 +1,27 @@ //! Character-sheet resistances from the coordinator's entity, aura, item and catalogue caches. -//! The fold mirrors Module combat resistance, including applied enchantments and Random Properties. +//! The shared combat fold includes applied enchantments and Random Properties. //! Other character-sheet values come directly from the Module's derived entity fields. use super::bindings::*; use spacetimedb_sdk::Table; use std::collections::HashMap; -/// Aura kind `A_MOD_RESISTANCE` — a direct resistance/armor bonus (e.g. Demon Skin). Mirrors -/// `module::spell::taxonomy::A_MOD_RESISTANCE = 0xA1`. -const A_MOD_RESISTANCE: u8 = 0xA1; -const RESISTANCE_BITS: [u32; 7] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]; -const RESISTANCE_KINDS: [u8; 7] = [ - lyracore_shared::item_property::ARMOR, - lyracore_shared::item_property::HOLY_RESISTANCE, - lyracore_shared::item_property::FIRE_RESISTANCE, - lyracore_shared::item_property::NATURE_RESISTANCE, - lyracore_shared::item_property::FROST_RESISTANCE, - lyracore_shared::item_property::SHADOW_RESISTANCE, - lyracore_shared::item_property::ARCANE_RESISTANCE, -]; -/// Inclusive upper bound of the equipment region (`equip_slot::END == TABARD == 18`). Only worn pieces in -/// slots 0..=18 carry armor; bags/backpack/bank don't count. Mirrors `items::equipped_stat_bonus`. -const EQUIP_REGION_END: u8 = 18; +use lyracore_shared::{ + aura::AuraAmount, + resistance::{self, ResistanceItem}, +}; -/// One aura row's armor contribution: `amount × max(stacks, 1)` IF it is an `A_MOD_RESISTANCE` aura whose -/// frozen `eff_p0` school mask overlaps `RESIST_ARMOR` (bit 0); else 0. Mirrors the module's -/// `resistance_bonus` predicate + `stacked_amount`: `stacks` is floored at 1 so a legacy 0-stack row still -/// counts ONCE, and a negative `amount × stacks` (Sunder Armor) subtracts. Pure → unit-tested. -fn aura_resistance_contribution( - eff_kind: u8, - eff_p0: i32, - amount: i32, - stacks: u8, - school_bit: u32, -) -> i32 { - if eff_kind == A_MOD_RESISTANCE && (eff_p0 as u32 & school_bit) != 0 { - amount.saturating_mul(i32::from(stacks.max(1))) - } else { - 0 - } -} - -fn aura_armor_contribution(eff_kind: u8, eff_p0: i32, amount: i32, stacks: u8) -> i32 { - aura_resistance_contribution(eff_kind, eff_p0, amount, stacks, RESISTANCE_BITS[0]) -} - -/// One equipped item's armor contribution: its template `stat_armor`, or 0 if the item is outside the -/// equipment region (`slot > 18`) or BROKEN (`max_durability > 0 && durability == 0`). Mirrors the -/// module's `equipped_stat_bonus(Armor)` (slot gate + `item_is_broken`). A no-durability item -/// (`max_durability == 0`, e.g. a cloak) is never broken. Pure → unit-tested. -fn gear_armor_contribution(slot: u8, stat_armor: i32, max_durability: u32, durability: u32) -> i32 { - if slot > EQUIP_REGION_END { - return 0; - } - if max_durability > 0 && durability == 0 { - return 0; // broken → grants no armor until repaired - } - stat_armor +fn aura_values(db: &RemoteTables, guid: u64) -> Vec { + db.game_aura() + .iter() + .filter(move |a| a.target_guid == guid) + .map(|a| AuraAmount { + kind: a.eff_kind, + parameter: a.eff_p0, + amount: a.amount, + stacks: a.stacks, + }) + .collect() } /// Everything the paperdoll shows that GEAR/AURAS move on the STR/AGI/STA/INT/SPI/AP/damage-range/crit @@ -103,12 +68,7 @@ pub(crate) fn sheet_stats(db: &RemoteTables, guid: u64) -> Option u32 { - db.game_aura() - .iter() - .filter(|a| a.target_guid == guid) - .map(|a| aura_armor_contribution(a.eff_kind, a.eff_p0, a.amount, a.stacks)) - .filter(|&c| c > 0) - .fold(0u32, |total, amount| total.saturating_add(amount as u32)) + resistance::positive_armor(aura_values(db, guid)) } /// Effective armor followed by Holy, Fire, Nature, Frost, Shadow and Arcane resistance. @@ -118,84 +78,63 @@ pub(crate) fn effective_resistances(db: &RemoteTables, guid: u64) -> [u32; 7] { .game_world_entity() .guid() .find(&guid) - .map(|e| e.armor as i64) - .unwrap_or(0); - let mut aura = [0i64; 7]; - for row in db.game_aura().iter().filter(|a| a.target_guid == guid) { - for (index, school_bit) in RESISTANCE_BITS.into_iter().enumerate() { - aura[index] += i64::from(aura_resistance_contribution( - row.eff_kind, - row.eff_p0, - row.amount, - row.stacks, - school_bit, - )); - } - } - // The SDK exposes no non-unique enchantment index. Read the catalogue once per projection. - let mut enchantments = HashMap::<(u32, u8), i32>::new(); - for effect in db - .game_item_enchantment() - .iter() - .filter(|effect| effect.enchant_id != 0 && RESISTANCE_KINDS.contains(&effect.kind)) - { - let total = enchantments - .entry((effect.enchant_id, effect.kind)) - .or_default(); - *total = total.saturating_add(effect.amount); - } - let enchant_stat = |id, kind| enchantments.get(&(id, kind)).copied().unwrap_or(0); - let templates = db.game_item_template(); - let mut gear = [0i32; 7]; - for item in db + .map_or(0, |e| e.armor); + let auras = aura_values(db, guid); + let items: Vec<_> = db .game_item_instance() .iter() - .filter(|i| i.owner_guid == guid && i.slot <= EQUIP_REGION_END) - { - let Some(tmpl) = templates.entry().find(&item.entry) else { - continue; - }; - if tmpl.max_durability > 0 && item.durability == 0 { - continue; - } - let template = [ - tmpl.stat_armor, - tmpl.holy_res, - tmpl.fire_res, - tmpl.nature_res, - tmpl.frost_res, - tmpl.shadow_res, - tmpl.arcane_res, - ]; - let property = db - .game_item_random_property() - .property_id() - .find(&item.random_property_id); - for index in 0..RESISTANCE_KINDS.len() { - let kind = RESISTANCE_KINDS[index]; - let mut amount = template[index].saturating_add(enchant_stat(item.enchant_id, kind)); - if let Some(property) = &property { - let property_amount = [ - property.enchant_id_1, - property.enchant_id_2, - property.enchant_id_3, - ] - .into_iter() - .map(|id| enchant_stat(id, kind)) - .fold(0i32, i32::saturating_add); - amount = amount.saturating_add(property_amount); - } - let amount = if index == 0 { - gear_armor_contribution(item.slot, amount, tmpl.max_durability, item.durability) - } else { - amount - }; - gear[index] = gear[index].saturating_add(amount); - } + .filter(|i| i.owner_guid == guid) + .filter_map(|item| { + let tmpl = db.game_item_template().entry().find(&item.entry)?; + let property = (item.random_property_id != 0) + .then(|| { + db.game_item_random_property() + .property_id() + .find(&item.random_property_id) + }) + .flatten(); + Some(ResistanceItem { + slot: item.slot, + max_durability: tmpl.max_durability, + durability: item.durability, + template: [ + tmpl.stat_armor, + tmpl.holy_res, + tmpl.fire_res, + tmpl.nature_res, + tmpl.frost_res, + tmpl.shadow_res, + tmpl.arcane_res, + ], + enchant_id: item.enchant_id, + property_enchants: property + .map_or([0; 3], |p| [p.enchant_id_1, p.enchant_id_2, p.enchant_id_3]), + }) + }) + .collect(); + // The SDK exposes no non-unique enchantment index. Read its catalogue once per projection. + let mut enchantments = HashMap::>::new(); + for effect in db.game_item_enchantment().iter() { + enchantments.entry(effect.enchant_id).or_default().push(( + effect.kind, + effect.amount, + effect.school_mask, + )); } std::array::from_fn(|index| { - let base = if index == 0 { base_armor } else { 0 }; - (base + aura[index] + i64::from(gear[index])).clamp(0, i64::from(u32::MAX)) as u32 + let bonus = resistance::resistance_bonus( + resistance::SCHOOL_BITS[index], + auras.iter().copied(), + items.iter().copied(), + |id, kind| { + lyracore_shared::item_property::enchant_stat( + kind, + u32::MAX, + enchantments.get(&id).into_iter().flatten().copied(), + ) + }, + ); + resistance::effective_resistance(if index == 0 { base_armor } else { 0 }, bonus) }) } @@ -261,69 +200,10 @@ mod tests { coordinator.effective_magic_resistances(1) == [0; 6] })); shard.assert_call("debug_fill_aura_slots", &["1", "1", "false", "3"]); - shard.assert_sql("UPDATE game_aura SET eff_kind = 161, eff_p0 = 1, amount = 2147483647, stacks = 255 WHERE target_guid = 1"); + shard.assert_sql("UPDATE game_aura SET eff_kind = 161, eff_p0 = 1, amount = 100, stacks = 3 WHERE target_guid = 1"); assert!(poll_until(POLL_TIMEOUT, || { let guard = coordinator.0.coord(); - aura_armor_positive(&guard.conn.db, 1) == u32::MAX + aura_armor_positive(&guard.conn.db, 1) == 900 })); } - - #[test] - fn armor_aura_contribution_matches_module_predicate() { - // A_MOD_RESISTANCE(0xA1) whose eff_p0 mask includes RESIST_ARMOR(0x01) → amount × max(stacks,1). - assert_eq!(aura_armor_contribution(0xA1, 0x01, 160, 1), 160); // Demon Skin-style flat +160 - // stacks floored at 1: a legacy 0-stack row still counts ONCE (not zero). - assert_eq!(aura_armor_contribution(0xA1, 0x01, 160, 0), 160); - // Stacking, negative (Sunder Armor-style): amount × stacks subtracts. - assert_eq!(aura_armor_contribution(0xA1, 0x01, -90, 5), -450); - assert_eq!( - aura_armor_contribution(0xA1, 0x01, i32::MAX, u8::MAX), - i32::MAX - ); - assert_eq!( - aura_armor_contribution(0xA1, 0x01, i32::MIN, u8::MAX), - i32::MIN - ); - // A school MASK that merely INCLUDES the armor bit still counts (mask test, not ==). - assert_eq!(aura_armor_contribution(0xA1, 0x03, 50, 1), 50); - // A non-armor school (eff_p0 == 0x02, e.g. holy) does NOT touch armor. - assert_eq!(aura_armor_contribution(0xA1, 0x02, 160, 1), 0); - // A different aura kind (not A_MOD_RESISTANCE) is ignored. - assert_eq!(aura_armor_contribution(0x1D, 0x01, 160, 1), 0); - } - - #[test] - fn gear_armor_contribution_gates_region_and_broken() { - // A worn chest (slot 4) with 120 armor contributes it. - assert_eq!(gear_armor_contribution(4, 120, 100, 100), 120); - // BROKEN (max_durability > 0, durability == 0) → grants nothing until repaired. - assert_eq!(gear_armor_contribution(4, 120, 100, 0), 0); - // A no-durability item (max_durability == 0, e.g. a cloak) is NEVER broken → contributes. - assert_eq!(gear_armor_contribution(14, 30, 0, 0), 30); - // Outside the equipment region (a backpack slot 23) contributes nothing. - assert_eq!(gear_armor_contribution(23, 120, 100, 100), 0); - // The region boundary (TABARD == 18) is INCLUSIVE. - assert_eq!(gear_armor_contribution(18, 5, 0, 0), 5); - } - - // The former `melee_attack_power_for`/`swing_range_ap` gateway-side mirrors (and the parity tests - // that pinned them against `lyracore_module`) are GONE: `sheet_stats` above is now a plain - // read of `module::spell::recompute_sheet`'s output, so there is no gateway-side formula left to - // drift from the module — the module row IS the source of truth. - - #[test] - fn aura_and_gear_terms_compose_additively_before_the_caller_clamps() { - // `effective_armor` itself needs a live `RemoteTables` subscription cache and is NOT - // unit-tested here (no ReducerContext mocking) — this only pins that its two REAL pure - // terms (`aura_armor_contribution`, `gear_armor_contribution`) compose by simple addition - // with a base, and that the max(0, ..) clamp `effective_armor` applies to that same sum - // never underflows on a debuff exceeding base + gear. - let base = 40i64; // agility 20 * 2 - let aura = aura_armor_contribution(0xA1, 0x01, 160, 1) as i64; // Demon Skin - let gear = gear_armor_contribution(4, 120, 100, 100) as i64; // a chest - assert_eq!(base + aura + gear, 320); - // A debuff exceeding base + gear clamps at 0 (never underflows the u32). - let big_debuff = aura_armor_contribution(0xA1, 0x01, -1000, 1) as i64; - assert_eq!((base + big_debuff + gear).max(0) as u32, 0); - } } diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index bc8f33989..7b946bb3c 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1035,227 +1035,59 @@ fn call_pipe_needs_repair(is_healthy: bool, already_reconnecting: bool) -> bool /// any of them on a single-database gateway anyway — `settle_home_shard` short-circuits on /// `is_sharded()` before it looks at the index. fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { - let mut queries = vec![ + let mut queries = crate::stdb::relays::queries(); + queries.extend([ "SELECT * FROM game_realm", "SELECT * FROM game_account", "SELECT * FROM game_session", - "SELECT * FROM game_account_claim", "SELECT * FROM game_account_fence", "SELECT * FROM game_account_character_owner", - "SELECT * FROM game_character", - "SELECT * FROM game_world_entity", - // Loot Tag rendering is viewer-relative. These rows let the Gateway project the stored - // dynamic flags on its shared CREATE and VALUES paths. `game_group_member` stays here as - // well: a captured party member who leaves loses the viewer-relative tap bit immediately. "SELECT * FROM game_creature_quest_tap", "SELECT * FROM game_creature_quest_tap_member", "SELECT * FROM game_creature_loot_tag_group", "SELECT * FROM game_corpse_loot_eligible", - "SELECT * FROM game_group_member", "SELECT * FROM game_auction", "SELECT * FROM game_auction_bid_decision", "SELECT * FROM game_auction_bid_hold", "SELECT * FROM game_auction_hold", "SELECT * FROM game_auction_house", - "SELECT * FROM game_auction_notice", "SELECT * FROM game_auction_operation_receipt", - // Guilds. Realm-core holds every row; World Shards hold none. The Gateway reads membership - // for the Guild Projection and renders Guild Events from this cache. "SELECT * FROM game_guild", "SELECT * FROM game_guild_rank", - "SELECT * FROM game_guild_member", - "SELECT * FROM game_guild_event", - // A Fee Hold lives on the payer's Home Shard, the fee decision on Realm-core. "SELECT * FROM game_guild_fee_hold", "SELECT * FROM game_guild_fee_decision", - // `game_guild_invite` is NOT subscribed here: every invite Gate and read runs inside - // `realm_guild_op`, so no Gateway code ever reads the row back from a cache. - // Petitions and their Signatures live on Realm-core. The Gateway renders the signature - // window, the petition query and the Guild Charter's Petition id from them. - "SELECT * FROM game_guild_petition", "SELECT * FROM game_guild_petition_signature", - // The four box-scoped tables now ride THIS one global subscription per shard instead - // of ~600 per-player 5×5-box subscriptions, and the gateway's own cell index - // (`stdb::world_index`) decides who sees each row. `game_world_entity` and - // `game_gameobject` were already here (the coordinator has always read them RLS-bypassed); - // these two are the whole server-side delta the change needed. - // - // The point is NOT that these queries are cheaper to evaluate — a `SELECT *` is the - // cheapest possible query, but that was never the cost. The cost was that ~600 DISTINCT - // query strings can neither be shared nor pruned, so every committed transaction woke ~600 - // SDK pumps to re-evaluate them: load average 11–14 on 8 cores at 34% CPU with 7 runnable - // threads (clockworklabs/SpacetimeDB). One subscription wakes one pump. - // - // ⚠ These are the two HIGHEST-RATE tables in the module (one upsert per mover per movement - // heartbeat, one per creature leg). They are handled by `world_view`'s dispatch, which does - // index bookkeeping and an enqueue per recipient and NOTHING else on the pump — no - // encoding, no per-session locks held. Adding work to that path is adding it to a thread - // every session on the shard depends on. - "SELECT * FROM game_entity_motion", - "SELECT * FROM game_creature_spline", - "SELECT * FROM game_taxi_passenger_spline", - // The SOURCE-side escrow. The ONE module→gateway data flow the cross-database - // transfer adds: the export blob is written here by `begin_transfer` and the gateway - // carries it to the other database's `import_character_blob`. Private table, read - // through the owner token like game_account/game_session. Also the whole of transfer - // RECOVERY: a restarted gateway re-derives an in-flight transfer's id, destination and - // payload from this row alone. - // - // Escrows, arrival fences and source instance leases are base subscriptions on every - // topology. Private Transfer state is read with the Owner Token. "SELECT * FROM game_transfer_out", "SELECT * FROM game_transfer_in", "SELECT * FROM game_instance", - // Stable Coordinator relays keep addressed events available across player subscription - // changes. Callbacks route them through the World View's owner and identity indexes. - "SELECT * FROM game_teleport_event", - "SELECT * FROM game_xp_event", - "SELECT * FROM game_levelup_event", - // Session-less Group Intents run on every topology, including a single database. "SELECT * FROM game_bot_invite_intent", "SELECT * FROM game_party_command_intent WHERE pending = true", "SELECT * FROM game_party_command_dispatch_lane", "SELECT * FROM game_party_command_receipt", - // Bot-initiated Shard crossings, here for every reason the invite intent above is: a bot has - // no session, so no other connection could see the row, and it rides the BASE list because a - // single-database realm writes the same rows (the relay finds nothing to cross there and - // says so). Its durable dispatcher drives the SAME Escrow sequence a player's - // `MSG_MOVE_WORLDPORT_ACK` does. "SELECT * FROM game_bot_transfer_intent", - // Addon-bridge messages: the server→client UI stream — coordinator-ridden - // from day one (the coordinator-relay rule). Any addon UI that streams live state rides - // this, and a dropped frame is a stuck widget on the client. - "SELECT * FROM game_addon_message", - // Explored areas (a live find, same rule): the fog-word relay's coordinator leg — a fresh - // login's first-movement discovery lands exactly in the per-player AOI-churn window. - "SELECT * FROM game_character_explored", - // Rest-state flips: same shape — `world_view:: - // rest_state_appeared` relays each row to its owner's session only. TTL-reaped. - "SELECT * FROM game_rest_state_event", - // The live sky, one row per zone that has weather. Read at world entry and at a zone - // crossing, and relayed by `world_view::weather_changed` to exactly the viewers whose - // stored zone matches the row's. Durable state and relay source in the same row, so a - // reconnect replays what everyone else already has. Tiny (one row per zone) and slow (one - // roll per zone every ten minutes). - "SELECT * FROM game_zone_weather", - // Breath timer edges and server-resolved drowning hits: self-only relay, with the same - // owner-session audience as rest state. The bar counts down client-side between edges. - "SELECT * FROM game_breath_relay_event", - // Instance Removal countdowns: `world_view` relays each row to its owner, and the world - // entry sweep replays a running one with the time left. The row is durable Module state, - // so a Gateway restart loses no countdown. - "SELECT * FROM game_instance_removal", - // Ground-area spell visuals: `world_view::dynobj_appeared` - // relays instance-gated CREATE/DESTROY. Short-lived rows (the area's duration). - "SELECT * FROM game_dynamic_object", - // Swing log + combat stance: `world_view:: - // combat_event_appeared` / `melee_engaged` / `melee_disengaged` run the shared - // per-viewer bodies. Both TTL-reaped/ephemeral combat tables. - "SELECT * FROM game_combat_event", - "SELECT * FROM game_melee_attack", - // The social tier: emote broadcast and range-gated say/yell - // (`world_view::emote_appeared`/`chat_appeared`). - "SELECT * FROM game_chat_event", - "SELECT * FROM game_emote_event", - // Creature virtual-item display projection. Durable rows are replayed after peer CREATE; - // insert/update/delete also relay the three sparse UNIT fields live. - "SELECT * FROM game_encounter_equip", - // Cast visuals + projectile impact: `world_view:: - // cast_event_appeared` runs the full cast-lock contract per viewer; `impact_appeared` - // the deferred damage number. Both TTL-reaped event tables. - "SELECT * FROM game_spell_cast_event", - "SELECT * FROM game_spell_impact_event", - // Pet bar spells: tiny static table `offer_peer_create_for` joins for the - // viewer's own pet CREATE (SMSG_PET_SPELLS slots 3–6) — read HERE since the first - // shared-dispatch move, but for a while not subscribed here at all: the only subscription - // left was the per-player copy nothing read any more, and the pet bar came up empty. "SELECT * FROM game_creature_cast", - // Bounded Hunter-pet protocol projection. The durable care row stays module-private; this - // carries only fields required to render the owner's live pet and answer its name query. - "SELECT * FROM game_hunter_pet_protocol", - // The PRIVATE tier: recipient-addressed rows the shared - // dispatch delivers through the owner-session lookup + private_recipient_audience. The - // owner token bypasses each row's per-recipient RLS — the gateway predicate IS the - // privacy guarantee on this path. In the BASE list (not the sharded block) so a - // single-database gateway's shared path works too; on realm-core the same subscription - // additionally feeds the per-session cross-shard twins, unchanged. - "SELECT * FROM game_resurrect_request", - "SELECT * FROM game_self_resurrect_option", - "SELECT * FROM game_system_message_event", - "SELECT * FROM game_mail_arrival", - "SELECT * FROM game_group_event", - "SELECT * FROM game_trade_event", - "SELECT * FROM game_duel_event", - // Realm Chat Lines carry their whole audience. Only Realm-core writes them; every - // database subscribes so an unsharded Realm hears them on its one connection. - "SELECT * FROM game_realm_chat_event", - // Auto-Replies, beside the live entity on each Home Shard. A whisper reads the target's - // one row by primary key (`world::presence::auto_reply`). "SELECT * FROM game_character_away", - // Chat Channels and their Channel Notices. Only Realm-core writes them. The channel and - // member tables answer `/chatlist` and the owner query through `ChannelIndex`; the - // notices carry their whole audience like Realm Chat Lines. "SELECT * FROM game_chat_channel", "SELECT * FROM game_chat_channel_member", "SELECT * FROM game_chat_channel_ban", - "SELECT * FROM game_chat_channel_notice_event", - // The Meeting Stone Queue. Stones are static and read on the Home Shard to name a stone's - // dungeon area; Seekers are written on Realm-core and answer `CMSG_MEETINGSTONE_INFO`. - // Both are small, and an unsharded Realm reads both on its one connection. "SELECT * FROM game_meeting_stone", "SELECT * FROM game_meeting_stone_seeker", - // Server-wide tunables. The gateway reads ONE column: `hosts_instances`, at - // startup, to answer "when this realm creates a dungeon instance, will anything actually - // spawn its population" (`ShardMap::check_instance_hosting`). Before this subscription the - // gateway could only GUESS — the old `instance_hosting_reminder` warned on every sharded - // startup regardless of the flag's value, and the far more damaging inverse (flag false with - // no routing ⇒ every dungeon created with 0 entities) was undetectable in either process. - // - // BASE list, not `sharded_tables`, and that is load-bearing: that empty-dungeon failure - // happens with NO shard map at all, so a single-database gateway is exactly the one that - // must read the flag. `game_config` is public and predates all of the sharding work, so - // subscribing it cannot brick the restart of a gateway whose module predates - // multi-database routing (the hazard `coordinator_queries`' doc comment describes). Its - // BINDING must stay in sync, though — `hosts_instances` was END-appended after the - // generated `server_config_type.rs` was last regenerated, which - // `gateway/tests/schema_parity.rs` now pins. "SELECT * FROM game_config", "SELECT * FROM game_creature_template", - // AreaTable catalogue: zone names for `/who`'s search-string match. Static and small - // enough for one coordinator cache, like every other imported catalogue on this list. "SELECT * FROM game_area", - // Taxi catalogue. Static and small enough for one coordinator cache; later discovery and - // activation slices resolve nodes, directed routes, and ordered path points from here. "SELECT * FROM game_taxi_node", "SELECT * FROM game_taxi_path", "SELECT * FROM game_taxi_path_node", - // Private, transient, request-id-keyed mailbox written by the module's cohesive status/open - // operations. The owner-token coordinator may subscribe it; discovery stays module-private - // policy and is deliberately not subscribed. "SELECT * FROM game_taxi_service_reply", "SELECT * FROM game_start_position", - "SELECT * FROM game_corpse", - // Items: the static item definitions (for CMSG_ITEM_QUERY_SINGLE) + every - // owned-item instance. The coordinator authenticates as the module owner, which bypasses - // RLS, so it reads all players' item rows (like game_character) to drive each login spawn. "SELECT * FROM game_item_template", "SELECT * FROM game_item_random_property", "SELECT * FROM game_item_enchantment", - // Static spell headers — spell_cast_time() reads cast_time_ms via coord() so the - // CMSG_CAST_SPELL handler can clear INSTANT casts synchronously. "SELECT * FROM game_spell", - // Spell effects — the gateway reads these to route ITEM-target enchant/disenchant casts by - // effect KIND (E_ENCHANT_ITEM/E_DISENCHANT) instead of a hardcoded spell-id list. "SELECT * FROM game_spell_effect", - "SELECT * FROM game_item_instance", - // Buyback ring: read by the coordinator to rebuild a player's buyback-tab view - // after sell/buyback and at login (private table — the owner-token coordinator sees it). "SELECT * FROM game_character_buyback", - // Items: a creature corpse's item loot, read for the loot window. "SELECT * FROM game_corpse_loot", - // Vendors (Tier 2): a creature's vendor stock, read for SMSG_LIST_INVENTORY. "SELECT * FROM game_npc_vendor", - // Gossip menus, runtime per-creature selections, NPC text, and clickable options. "SELECT * FROM game_gossip_menu", "SELECT * FROM game_gossip_option", "SELECT * FROM game_gossip_menu_profile", @@ -1263,111 +1095,38 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { "SELECT * FROM game_creature_gossip_menu_override", "SELECT * FROM game_npc_text", "SELECT * FROM game_npc_text_slot", - // Quests (gateway slice): the static quest tables + every player's quest log. The - // coordinator bypasses RLS, so game_character_quest is read for any player to compute the - // overhead `!`/`?` status + the completion gate. "SELECT * FROM game_quest_template", "SELECT * FROM game_quest_text", "SELECT * FROM game_quest_objective", "SELECT * FROM game_quest_reward_item", "SELECT * FROM game_quest_reward_choice", "SELECT * FROM game_creature_quest", - // The GAMEOBJECT twin of `game_creature_quest` — GO 68 "Wanted Poster" - // starts q176 with no creature giver at all, and the GO 55/56 "Lost Guards" corpses drive - // the q37/q45/q71 END chain. Without this subscription the coordinator's `RemoteTables` - // cache of `game_gameobject_quest` stays empty forever (the binding/decode plumbing landed - // earlier, but nothing ever asked the node to send rows) and `quest_giver_evals`'s GO - // fallback would silently evaluate against zero relations no matter what the module holds. "SELECT * FROM game_gameobject_quest", - "SELECT * FROM game_character_quest", - // The player's learned spells, chained into the login spellbook (e.g. Auto Shot). "SELECT * FROM game_player_spell", - // Imported action-bar rows (hand-written binding — see - // `stdb/bindings/player_action_type.rs`'s header note). Empty until a dump import runs; - // the coordinator's own read cache needs this subscription the same way game_player_spell - // does above (RLS-bypassed, so player_actions() can read ANY player's rows for login). "SELECT * FROM game_player_action", - // Trainers: the static teach-list (SMSG_TRAINER_LIST) + every player's trained skills. The - // coordinator bypasses RLS, so game_player_skill is read for any player to compute each - // profession offering's "known" (gray-out a tier already trained past) in trainer_list. "SELECT * FROM game_trainer_spell", "SELECT * FROM game_spell_chain", - "SELECT * FROM game_player_skill", - // Gameobjects: spawned props + templates, relayed to clients as GameObject CREATE_OBJECT, - // joined for the query reply + the chest-loot use. - "SELECT * FROM game_gameobject", "SELECT * FROM game_gameobject_template", - // Talents: static talent metadata for the gateway to look up grant_spell_id on CMSG_LEARN_TALENT, - // plus every character's spent ranks (RLS-bypassed like game_player_skill) so the live pick - // relays the rank-spell (SMSG_LEARNED_SPELL/SUPERCEDED) and the decremented - // PLAYER_CHARACTER_POINTS1 — without these the 5875 talent pane freezes until relog. "SELECT * FROM game_talent", "SELECT * FROM game_character_talent", - // Spell-modifier mirror: the coordinator reads every player's A_SPELLMOD auras to - // build the SMSG_SET_FLAT/PCT_SPELL_MODIFIER packets at login + on a talent pick. - "SELECT * FROM game_aura", - // The player's persisted reputation standings, chained into the - // login SMSG_INITIALIZE_FACTIONS. Without this subscription the coordinator's local cache of - // game_player_reputation is empty and player_reputations() always returns nothing. - "SELECT * FROM game_player_reputation", - // Mail. Subscribed on EVERY connection in the set, because the mailbox has two planes: - // realm-core is authoritative on a sharded realm, and a single-database gateway reads its - // own copy. The gateway answers `CMSG_GET_MAIL_LIST` from this cache. The list itself is a - // poll; only the Mail Arrival above is relayed. "SELECT * FROM game_mail", - // The mail ESCROW ledger, and the `game_transfer_out` precedent verbatim: the gateway is - // the only component that can see both databases, so a fence its predecessor abandoned is - // re-derived from this row and driven forward. Private, read through the owner token. Every - // connection in the set, because a letter fences on the sender's shard and a take fences on - // realm-core. A single-database gateway holds only Reward Letters here, which the turn-in - // files on its one database. "SELECT * FROM game_mail_escrow", - // Letter Copy's readable item text (`CMSG_MAIL_CREATE_TEXT_ITEM`). Same two-plane shape as - // game_mail: a copied letter's text lives wherever the mail plane does, and it outlives the - // mail row that created it, so `CMSG_ITEM_TEXT_QUERY` must find it there even after the - // mail is gone. Private, read through the owner token. "SELECT * FROM game_item_text", - // Friends/ignore: every character's contact rows, so the coordinator can - // build any player's SMSG_FRIEND_LIST/SMSG_IGNORE_LIST (RLS-bypassed, like game_character). - "SELECT * FROM game_character_contact", - // Reaction gating: standing-derived reaction for interaction windows — - // the NPC's template resolves to its parent faction (rep bar or mask fallback). Both - // static/small (314 + ~470 rows). "SELECT * FROM game_faction", "SELECT * FROM game_faction_template", - // Guid ranges. Two one-row tables, subscribed on EVERY deployment because a database - // that holds no range refuses to create characters at all: `ensure_guid_ranges` reads the - // allocator's high-water to decide which range the database is already minting from, and - // reads back the range it ends up holding to log it. "SELECT * FROM game_guid_allocator", "SELECT * FROM game_guid_range", - ]; + ]); if sharded_tables { - // Character→shard index. On realm-core this is the realm-wide directory the login - // path consults; on a world shard it is the receipt `finish_transfer` writes in the escrow's - // own transaction. Private, like game_account/game_session — the owner token reads it, no - // client ever sees it. Multi-database deployments only (see this function's doc comment). - queries.push("SELECT * FROM game_character_shard"); - // Realm-core owns party membership; each World Shard retains its mirror. World reads need - // the roster and member revisions when Transfer replaces local rows. Events and members - // already live in the base subscription. - queries.push("SELECT * FROM game_group"); - queries.push("SELECT * FROM game_group_roster_revision"); - queries.push("SELECT * FROM game_group_member_partition"); - // Loot rolls — a DIFFERENT reason than every table above: nothing here is a CLIENT - // relay (`game_group_event` still carries every wire-visible roll transition, unchanged). The - // gateway's own loot-roll relay (`world::loot::relay_tick`) needs these two PRIVATE tables to - // do its job: on a WORLD SHARD, `pending_local_rolls` reads a freshly kill-time-created - // staging roll to promote onto realm-core; on REALM-CORE, the promoted/voted rows are what - // `remove_member`'s disband branch resolves directly. Subscribed on every connection in the - // set for the same reason party state is: each handle reads its own database's copy, and the - // relay decides which copy needs which action. - queries.push("SELECT * FROM game_loot_roll"); - queries.push("SELECT * FROM game_loot_roll_vote"); - // The guid-range REGISTRY — realm-core's answer to which shard mints from where. - // Only meaningful in a multi-database deployment: with one database there is one minter and - // nothing to arbitrate. - queries.push("SELECT * FROM game_guid_range_registry"); + queries.extend([ + "SELECT * FROM game_character_shard", + "SELECT * FROM game_group", + "SELECT * FROM game_group_roster_revision", + "SELECT * FROM game_group_member_partition", + "SELECT * FROM game_loot_roll", + "SELECT * FROM game_loot_roll_vote", + "SELECT * FROM game_guid_range_registry", + ]); } queries } diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index 6e4ff1d8a..4932c6592 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -16,7 +16,7 @@ //! - `reducers`: reducer calls no Store trait owns (relays, logon, provisioning, heartbeat). //! - `realm_db`: `RealmDb` for `Coordinator`, the Realm-core routing seam. //! - `store`: one Store family adapter per file. -//! - `subscriptions`: `PlayerSubscriptions`, viewer setup, and shared packet builders. +//! - `subscriptions`: `PlayerSubscriptions`, viewer setup, and durable background work. //! - `views`: row→view converters + the thin `RealmRow`/`AccountRow` mirrors. // The SpacetimeDB codegen writes one function per table subscription; several run past any @@ -26,17 +26,17 @@ pub mod bindings; mod account_sessions; pub(crate) mod aoi; // `world/mod.rs`'s 10s task reads `aoi::AOI_RECENTERS` for the AOISTAT line -mod armor; // the gateway-side EFFECTIVE-armor fold for the character sheet (Approach B) +pub(crate) mod armor; // Character sheet projection from Coordinator rows mod auction_holds; mod connection; mod member_stats_relay; // Member Stats for group mates outside the viewer's AOI -mod movement_batch; -mod reads; +pub(crate) mod movement_batch; +pub(crate) mod reads; mod realm_db; // impl RealmDb for Coordinator mod reducers; mod store; // one Store family adapter per file pub(crate) mod subscriptions; -mod views; +pub(crate) mod views; pub(crate) mod world_index; pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch @@ -49,3 +49,5 @@ pub use subscriptions::PlayerSubscriptions; // crate the re-export has no external consumer to mark it used, yet the path must stay resolvable. #[allow(unused_imports)] pub use views::{AccountRow, RealmRow}; + +pub(crate) mod relays; diff --git a/gateway/src/stdb/reads/mod.rs b/gateway/src/stdb/reads/mod.rs index f5a138b9a..375b65e44 100644 --- a/gateway/src/stdb/reads/mod.rs +++ b/gateway/src/stdb/reads/mod.rs @@ -41,9 +41,11 @@ pub(in crate::stdb) use spell::spell_ranks_stack_in_book; /// `items::item_count`. Shared by `quest::quest_objectives_complete` and /// `items::viewer_needs_quest_item`. pub(in crate::stdb) fn player_item_count(db: &RemoteTables, owner_guid: u64, entry: u32) -> u32 { - db.game_item_instance() - .iter() - .filter(|i| i.owner_guid == owner_guid && i.entry == entry) - .map(|i| i.stack_count) - .sum() + lyracore_shared::item::carried_quantity( + entry, + db.game_item_instance() + .iter() + .filter(|item| item.owner_guid == owner_guid) + .map(|item| (item.slot, item.entry, item.stack_count)), + ) } diff --git a/gateway/src/stdb/reads/quest.rs b/gateway/src/stdb/reads/quest.rs index 9210f675e..d05728d2e 100644 --- a/gateway/src/stdb/reads/quest.rs +++ b/gateway/src/stdb/reads/quest.rs @@ -12,20 +12,29 @@ pub(in crate::stdb) fn quest_objectives_complete( logrow: Option<&CharacterQuest>, owner_guid: u64, ) -> bool { - const COLLECT_ITEM: u8 = 1; // == module objective_kind::COLLECT_ITEM - db.game_quest_objective() + let requires_event_credit = db + .game_quest_event_requirement() .iter() - .filter(|o| o.quest_entry == quest_entry) - .all(|o| { - let have = if o.kind == COLLECT_ITEM { - player_item_count(db, owner_guid, o.target_entry) - } else { - logrow - .and_then(|q| q.counts.get(o.obj_index as usize).copied()) - .unwrap_or(0) - }; - have >= o.required_count - }) + .any(|requirement| requirement.quest_entry == quest_entry); + let has_event_credit = db + .game_character_quest_event_credit() + .iter() + .any(|credit| credit.character_guid == owner_guid && credit.quest_entry == quest_entry); + lyracore_shared::quest::objectives_complete( + db.game_quest_objective() + .iter() + .filter(|o| o.quest_entry == quest_entry) + .map(|o| lyracore_shared::quest::Objective { + kind: o.kind, + index: o.obj_index, + target_entry: o.target_entry, + required_count: o.required_count, + }), + logrow.map_or(&[], |q| q.counts.as_slice()), + |entry| player_item_count(db, owner_guid, entry), + requires_event_credit, + has_event_credit, + ) } /// Build the player's quest-log descriptor slots from raw quest + objective rows. Shared by the login @@ -34,7 +43,7 @@ pub(in crate::stdb) fn quest_objectives_complete( /// slot assignments. Active (un-rewarded) quests for `player_guid`, sorted by quest_entry → slot 0.., /// capped at the 20 vanilla slots; `state` is 1 when every objective is met (INVENTORY-AWARE for /// COLLECT_ITEM, via `quest_objectives_complete` — so the log shows "(Complete)" for a collect quest), -/// else 0. Reads `db` (game_quest_objective + game_item_instance); both callers' caches see this player. +/// else 0. Reads objectives, carried items, event requirements and event credits from the same cache. pub(crate) fn build_quest_log_slots( db: &RemoteTables, quests: &[CharacterQuest], diff --git a/gateway/src/stdb/reads/spell.rs b/gateway/src/stdb/reads/spell.rs index 718ae2892..8e5b3f7c5 100644 --- a/gateway/src/stdb/reads/spell.rs +++ b/gateway/src/stdb/reads/spell.rs @@ -1,6 +1,8 @@ //! Spell/spellbook cache-accessor methods — pure code-motion split of the former //! `reads.rs`. +use lyracore_shared::spell::taxonomy::SPELL_ATTR_PASSIVE; + use super::super::bindings::*; // --- quest read helpers (free fns over the cache, shared by the methods above) --------------------- @@ -14,7 +16,6 @@ use super::super::bindings::*; /// non-mana-cost spells (rage/energy/health) SUPERSEDE their old rank. Unknown spell → stack /// (never hide a rank on missing data). LOCKSTEP: SPELL_ATTR_PASSIVE = 0x40 (module taxonomy). pub(in crate::stdb) fn spell_ranks_stack_in_book(db: &RemoteTables, spell_id: u32) -> bool { - const SPELL_ATTR_PASSIVE: u32 = 0x40; const POWER_MANA: u8 = 0; match db.game_spell().spell_id().find(&spell_id) { Some(h) => h.attributes & SPELL_ATTR_PASSIVE == 0 && h.power_type == POWER_MANA, diff --git a/gateway/src/stdb/reads/talent_reputation.rs b/gateway/src/stdb/reads/talent_reputation.rs index e42ca2644..ec7b6f898 100644 --- a/gateway/src/stdb/reads/talent_reputation.rs +++ b/gateway/src/stdb/reads/talent_reputation.rs @@ -31,86 +31,54 @@ impl Coordinator { let Some(npc_ft) = templates.id().find(&npc.faction_template) else { return Ok(false); }; - if let Some(parent) = db.game_faction().faction_id().find(&npc_ft.faction) { - if parent.reputation_index >= 0 { - let row = db - .game_player_reputation() - .iter() - .find(|r| r.character_guid == player_guid && r.faction_id == parent.faction_id); - if let Some(row) = row { - // The At-War checkbox forces hostile regardless of standing (vanilla). - if row.at_war { - return Ok(true); - } - return Ok(reputation_rank(row.standing) <= RANK_UNFRIENDLY); - } - } - } - let Some(player_ft) = templates.id().find(&player.faction_template) else { - return Ok(false); - }; - Ok(faction_template_hostile(&npc_ft, &player_ft)) - } -} - -/// Reputation RANK for a raw standing (0=Hated .. 7=Exalted, Neutral=3). KEEP IN LOCKSTEP with -/// `module/src/reputation.rs::reputation_rank` — same mangos thresholds, duplicated because the -/// module fn is table-crate-private (promote both into lyracore_shared if a third consumer appears). -fn reputation_rank(standing: i32) -> u8 { - match standing { - s if s >= 42000 => 7, - s if s >= 21000 => 6, - s if s >= 9000 => 5, - s if s >= 3000 => 4, - s if s >= 0 => 3, - s if s >= -3000 => 2, - s if s >= -6000 => 1, - _ => 0, + let reputation = db + .game_faction() + .faction_id() + .find(&npc_ft.faction) + .filter(|parent| parent.reputation_index >= 0) + .and_then(|parent| { + db.game_player_reputation().iter().find(|row| { + row.character_guid == player_guid && row.faction_id == parent.faction_id + }) + }) + .map(|row| (row.standing, row.at_war)); + let template_hostile = templates + .id() + .find(&player.faction_template) + .is_some_and(|player_ft| faction_template_hostile(&npc_ft, &player_ft)); + Ok(lyracore_shared::faction::refuses_interaction( + reputation, + template_hostile, + )) } } -/// Refuse-interaction threshold: Unfriendly (2) and below refuse; Neutral (3)+ interacts. -const RANK_UNFRIENDLY: u8 = 2; -/// Is template `a` HOSTILE to template `b`? KEEP IN LOCKSTEP with -/// `module/src/faction.rs::compute_hostile` (the vanilla `FactionTemplate.dbc` relationship rule) — the -/// explicit enemy list wins, then the explicit friend list, else the group masks decide. Duplicated -/// over the gateway BINDING type for the interaction-window mask fallback. fn faction_template_hostile( a: &super::super::bindings::FactionTemplate, b: &super::super::bindings::FactionTemplate, ) -> bool { - if b.faction != 0 { - if [a.enemy_0, a.enemy_1, a.enemy_2, a.enemy_3].contains(&b.faction) { - return true; - } - if [a.friend_0, a.friend_1, a.friend_2, a.friend_3].contains(&b.faction) { - return false; - } - } - (a.enemy_group & b.faction_group) != 0 + lyracore_shared::faction::relation_holds( + &[a.enemy_0, a.enemy_1, a.enemy_2, a.enemy_3], + &[a.friend_0, a.friend_1, a.friend_2, a.friend_3], + a.enemy_group, + b.faction, + b.faction_group, + ) } #[cfg(test)] mod tests { - // The two pure reaction helpers, vectors mirrored from module/src/reputation.rs + - // module/src/faction.rs tests (the lockstep contract). #[test] fn reputation_rank_thresholds_match_mangos() { - use super::reputation_rank; + use lyracore_shared::faction::reputation_rank; assert_eq!(reputation_rank(42000), 7); // Exalted assert_eq!(reputation_rank(9000), 5); // Honored assert_eq!(reputation_rank(0), 3); // Neutral assert_eq!(reputation_rank(-1), 2); // Unfriendly assert_eq!(reputation_rank(-3001), 1); // Hostile assert_eq!(reputation_rank(-42000), 0); // Hated - assert!( - reputation_rank(-1) <= super::RANK_UNFRIENDLY, - "Unfriendly refuses" - ); - assert!( - reputation_rank(0) > super::RANK_UNFRIENDLY, - "Neutral interacts" - ); + assert!(reputation_rank(-1) <= 2, "Unfriendly refuses"); + assert!(reputation_rank(0) > 2, "Neutral interacts"); } #[test] diff --git a/gateway/src/stdb/relays.rs b/gateway/src/stdb/relays.rs new file mode 100644 index 000000000..c70c76127 --- /dev/null +++ b/gateway/src/stdb/relays.rs @@ -0,0 +1,278 @@ +//! Typed Relay declarations own both the Coordinator query and its row callbacks. + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Arc; + +use spacetimedb_sdk::{Table, TableWithPrimaryKey}; + +use super::bindings::{EventContext, Reducer, RemoteTables}; +use super::world_index::ShardId; +use super::world_view::{GroupEventSource, WorldView}; +use super::Coordinator; + +/// Initial subscription rows arrive as inserts. Updates and deletes are always live deltas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Replay { + Snapshot, + Live, +} + +impl Replay { + fn admits(self, event: &spacetimedb_sdk::Event) -> bool { + self == Self::Snapshot || !matches!(event, spacetimedb_sdk::Event::SubscribeApplied) + } +} + +#[derive(Clone)] +pub(crate) struct RelayContext { + pub(crate) view: Arc, + /// World reads use the default World Shard even for Realm-core callbacks. + pub(crate) coord: Coordinator, + /// Guild reads use the connection that owns the subscribed rows. + pub(crate) realm: Coordinator, + pub(crate) shard: ShardId, + pub(crate) source: GroupEventSource, +} + +pub(crate) struct RelayTable { + #[cfg(test)] + pub(crate) table: &'static str, + pub(crate) query: &'static str, + #[cfg(test)] + pub(crate) realm: bool, + #[cfg(test)] + pub(crate) legs: &'static [(&'static str, Replay)], +} + +/// The declaration's table accessor fixes the row type of every callback. The same entry emits +/// its query, replay policy and registration, so adding a Relay cannot omit its subscription. +macro_rules! declare_relays { + ($($table:ident [$realm:literal] { + $($leg:ident($replay:ident) => $callback:expr;)* + })*) => { + pub(crate) const RELAYS: &[crate::stdb::relays::RelayTable] = &[ + $(crate::stdb::relays::RelayTable { + #[cfg(test)] + table: stringify!($table), + query: concat!("SELECT * FROM ", stringify!($table)), + #[cfg(test)] + realm: $realm, + #[cfg(test)] + legs: &[$((stringify!($leg), crate::stdb::relays::Replay::$replay)),*], + }),* + ]; + + pub(crate) fn register_relays( + context: &crate::stdb::relays::RelayContext, + db: &crate::stdb::bindings::RemoteTables, + realm_only: bool, + ) { + $(if !realm_only || $realm { + $(crate::stdb::relays::$leg( + db.$table(), + concat!(stringify!($table), ".", stringify!($leg)), + context.clone(), + crate::stdb::relays::Replay::$replay, + $callback, + );)* + })* + } + }; +} +pub(crate) use declare_relays; + +fn guarded(label: &str, body: impl FnOnce()) { + if catch_unwind(AssertUnwindSafe(body)).is_err() { + log::error!( + "Relay `{label}` panicked; this delta was dropped and the Shard pump continues" + ); + } +} + +pub(crate) fn insert>( + table: T, + label: &'static str, + context: RelayContext, + replay: Replay, + mut callback: impl FnMut(&RelayContext, &EventContext, &T::Row) + Send + 'static, +) { + table.on_insert(move |event, row| { + if replay.admits(&event.event) { + guarded(label, || callback(&context, event, row)); + } + }); +} + +pub(crate) fn update>( + table: T, + label: &'static str, + context: RelayContext, + _replay: Replay, + mut callback: impl FnMut(&RelayContext, &EventContext, &T::Row, &T::Row) + Send + 'static, +) { + table.on_update(move |event, old, row| { + guarded(label, || callback(&context, event, old, row)); + }); +} + +pub(crate) fn delete>( + table: T, + label: &'static str, + context: RelayContext, + _replay: Replay, + mut callback: impl FnMut(&RelayContext, &EventContext, &T::Row) + Send + 'static, +) { + table.on_delete(move |event, row| { + guarded(label, || callback(&context, event, row)); + }); +} + +macro_rules! protocol_relays { + ($($family:ident),* $(,)?) => { + pub(crate) fn tables() -> impl Iterator { + [$(crate::codec::$family::subscriptions::RELAYS),*].into_iter().flatten() + } + + fn register(context: RelayContext, db: &RemoteTables, realm_only: bool) { + $(crate::codec::$family::subscriptions::register_relays(&context, db, realm_only);)* + } + }; +} + +protocol_relays!( + addon, auction, breath, channel, combat, corpse, duel, entity, gameobject, guild, item, + leveling, mail, movement, pet, quest, social, taxi, trainer, weather, +); + +pub(crate) fn queries() -> Vec<&'static str> { + tables().map(|table| table.query).collect() +} + +pub(crate) fn register_shard( + view: Arc, + coord: Coordinator, + shard: ShardId, + db: &RemoteTables, +) { + register( + RelayContext { + view, + realm: coord.clone(), + coord, + shard, + source: GroupEventSource::Shard(shard), + }, + db, + false, + ); +} + +pub(crate) fn register_realm( + view: Arc, + realm: Coordinator, + coord: Coordinator, + db: &RemoteTables, +) { + register( + RelayContext { + view, + coord, + realm, + shard: 0, + source: GroupEventSource::RealmCore, + }, + db, + true, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn declarations_subscribe_each_registered_table_once() { + let tables: Vec<_> = tables().collect(); + let unique: BTreeSet<_> = tables.iter().map(|table| table.table).collect(); + assert!(!tables.is_empty()); + assert_eq!(unique.len(), tables.len()); + for table in tables { + assert_eq!(table.query, format!("SELECT * FROM {}", table.table)); + let legs: BTreeSet<_> = table.legs.iter().map(|(leg, _)| *leg).collect(); + assert_eq!( + legs.len(), + table.legs.len(), + "duplicate callback on {}", + table.table + ); + assert!(!legs.is_empty()); + } + } + + #[test] + fn realm_core_registers_only_the_private_nine_table_subset() { + let realm: BTreeSet<_> = tables() + .filter(|table| table.realm) + .map(|table| table.table) + .collect(); + assert_eq!( + realm, + BTreeSet::from([ + "game_account_claim", + "game_auction_notice", + "game_chat_channel_notice_event", + "game_group_event", + "game_guild_event", + "game_guild_member", + "game_guild_petition", + "game_mail_arrival", + "game_realm_chat_event", + ]) + ); + } + + #[test] + fn initial_apply_drops_only_the_live_insert_legs() { + let event = spacetimedb_sdk::Event::SubscribeApplied; + let suppressed: BTreeSet<_> = tables() + .filter(|table| { + table + .legs + .iter() + .any(|(leg, replay)| *leg == "insert" && !replay.admits(&event)) + }) + .map(|table| table.table) + .collect(); + assert_eq!( + suppressed, + BTreeSet::from([ + "game_addon_message", + "game_auction_notice", + "game_chat_channel_notice_event", + "game_duel_event", + "game_group_event", + "game_guild_event", + "game_item_instance", + "game_levelup_event", + "game_mail_arrival", + "game_player_reputation", + "game_realm_chat_event", + "game_resurrect_request", + "game_system_message_event", + "game_teleport_event", + "game_trade_event", + "game_xp_event", + ]) + ); + assert!(Replay::Snapshot.admits(&event)); + } + + #[test] + fn callback_panic_does_not_escape_the_shard_pump() { + let mut next_callback_ran = false; + guarded("test", || panic!("bad callback")); + guarded("next", || next_callback_ran = true); + assert!(next_callback_ran); + } +} diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs index 541a76ae6..b2a7e0be0 100644 --- a/gateway/src/stdb/store/cast.rs +++ b/gateway/src/stdb/store/cast.rs @@ -1,5 +1,10 @@ //! `Coordinator`'s [`CastStore`] adapter. +use lyracore_shared::spell::taxonomy::{ + E_DISENCHANT, E_ENCHANT_ITEM, E_FISH, E_NEXT_SWING, E_OPEN_LOCK, E_PERSISTENT_AREA, + SPELL_ATTR_RANGED_AUTO_REPEAT, +}; + use anyhow::Result; use spacetimedb_sdk::Table; @@ -30,8 +35,6 @@ impl CastStore for Coordinator { /// casts by effect KIND, with the enchant id carried in the effect's `p_0`, instead of a hardcoded /// spell-id list — a new enchant spell is a data row, no gateway change. fn enchant_route(&self, spell_id: u32) -> Option { - const E_ENCHANT_ITEM: u8 = 0x17; // taxonomy E_ENCHANT_ITEM (p_0 = enchant_id) - const E_DISENCHANT: u8 = 0x18; // taxonomy E_DISENCHANT self.0 .coord() .conn @@ -52,7 +55,6 @@ impl CastStore for Coordinator { /// spell's impact animation ON the paladin ("hit animation on the caster"). Kind-routed /// like `enchant_route` — a new ground spell is a data row. fn spell_is_ground_area(&self, spell_id: u32) -> bool { - const E_PERSISTENT_AREA: u8 = 0x1B; // lockstep with module taxonomy self.0 .coord() .conn @@ -65,7 +67,6 @@ impl CastStore for Coordinator { /// True iff `spell_id` is a FISHING cast (taxonomy `E_FISH` 0x1C — the three tier ids carry a /// synthesized marker effect row). Kind-routed like `enchant_route`/`spell_is_ground_area`. fn spell_is_fishing(&self, spell_id: u32) -> bool { - const E_FISH: u8 = 0x1C; // lockstep with module taxonomy self.0 .coord() .conn @@ -79,7 +80,6 @@ impl CastStore for Coordinator { /// the `pick_lock` reducer, gateway-intercepted exactly like `spell_is_fishing`/`enchant_route`. The /// GO guid the pick targets rides the cast's SpellCastTargets (GAMEOBJECT flag). fn spell_is_open_lock(&self, spell_id: u32) -> bool { - const E_OPEN_LOCK: u8 = 0x1D; // lockstep with module taxonomy self.0 .coord() .conn @@ -100,7 +100,6 @@ impl CastStore for Coordinator { /// 5875 client lights the button locally on the press and holds it as a pending cast until the /// swing-fire GO arrives. Kind value LOCKSTEP with module taxonomy.rs::E_NEXT_SWING (0x13). fn spell_queues_next_swing(&self, spell_id: u32) -> bool { - const E_NEXT_SWING: u8 = 0x13; let guard = self.0.coord(); let queues = guard .conn @@ -116,7 +115,6 @@ impl CastStore for Coordinator { /// AUTOREPEAT bit / by name). The CMSG_CAST_SPELL handler routes on this instead of a hardcoded /// `spell == 75 || 5019` id list, so a new ranged auto-repeat ability onboards as data. fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { - const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // lockstep with importer::spell.rs let guard = self.0.coord(); guard .conn diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index 6a1257b9d..f85b4c3d5 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -1,5 +1,7 @@ //! `Coordinator`'s [`CharacterStore`] adapter. +use lyracore_shared::spell::taxonomy::{A_SPELLMOD_FLAT, A_SPELLMOD_PCT}; + use anyhow::Result; use spacetimedb_sdk::Table; @@ -207,8 +209,6 @@ impl CharacterStore for Coordinator { /// rows — the client-mirror source for SMSG_SET_FLAT/PCT_SPELL_MODIFIER (aggregation by /// (op, mask-bit) happens in the codec helper; mangos sends the TOTAL per bit). fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - const A_SPELLMOD_FLAT: u8 = 0xAC; // lockstep with module taxonomy - const A_SPELLMOD_PCT: u8 = 0xAD; self.0 .coord() .conn diff --git a/gateway/src/stdb/store/loot_window.rs b/gateway/src/stdb/store/loot_window.rs index bc5960b04..1e6a4a37f 100644 --- a/gateway/src/stdb/store/loot_window.rs +++ b/gateway/src/stdb/store/loot_window.rs @@ -166,105 +166,51 @@ fn viewer_in_raid(guard: &LiveConn, viewer_guid: u64) -> bool { }) } -/// Does `viewer_guid` currently need quest item `item_entry`? The gateway twin -/// of the module's `loot::killer_needs_item`/`needs_item_pure` (an ACTIVE — un-rewarded — quest with a -/// `COLLECT_ITEM` objective naming `item_entry`, held < required), applied to the VIEWER opening the -/// loot window rather than the credited killer, and gated by the same raid quest-credit rule -/// (`lyracore_shared::quest::quest_progresses_for_raid`) so the loot window never promises an item -/// a Raid member's `take_loot` would then refuse (that Gate is `loot::killer_needs_item`, gated -/// identically). RLS-bypassed read (coordinator), same shape as -/// `quest_objectives_complete`/`player_item_count` above. +/// Read active collect objectives and carried quantities for the shared quest-item rule. fn viewer_needs_quest_item(guard: &LiveConn, viewer_guid: u64, item_entry: u32) -> bool { - const COLLECT_ITEM: u8 = 1; // == module objective_kind::COLLECT_ITEM let db = &guard.conn.db; let active: Vec = db .game_character_quest() .iter() - .filter(|q| q.character_guid == viewer_guid && !q.rewarded) - .map(|q| q.quest_entry) + .filter(|quest| quest.character_guid == viewer_guid && !quest.rewarded) + .map(|quest| quest.quest_entry) .collect(); - if active.is_empty() { - return false; - } - let in_raid = viewer_in_raid(guard, viewer_guid); - let objectives: Vec<(u32, u32, u32)> = db - .game_quest_objective() + let objective_table = db.game_quest_objective(); + let objectives = objective_table .iter() - .filter(|o| o.kind == COLLECT_ITEM && active.contains(&o.quest_entry)) - .map(|o| { - let quest_type = db + .filter(|objective| active.contains(&objective.quest_entry)) + .map(|objective| lyracore_shared::loot::QuestItemObjective { + quest_entry: objective.quest_entry, + quest_type: db .game_quest_template() .entry() - .find(&o.quest_entry) - .map_or(0, |t| t.quest_type); - (quest_type, o.target_entry, o.required_count) - }) - .collect(); - needs_quest_item_pure( - in_raid, - &objectives, + .find(&objective.quest_entry) + .map_or(0, |template| template.quest_type), + kind: objective.kind, + target_entry: objective.target_entry, + required_count: objective.required_count, + }); + lyracore_shared::loot::needs_quest_item( + viewer_in_raid(guard, viewer_guid), + &active, + objectives, item_entry, player_item_count(db, viewer_guid, item_entry), ) } -/// Pure decision behind `viewer_needs_quest_item`: does any of `objectives` (flattened -/// `(quest_type, target_entry, required_count)` for the viewer's active COLLECT_ITEM objectives) -/// want `item`, given the viewer's Raid membership? Split out so the raid quest-credit rule's -/// wiring is unit-testable without a live cache, mirroring the module's `needs_item_pure`. -fn needs_quest_item_pure( - in_raid: bool, - objectives: &[(u32, u32, u32)], - item: u32, - held: u32, -) -> bool { - objectives - .iter() - .any(|&(quest_type, target_entry, required_count)| { - target_entry == item - && held < required_count.max(1) - && lyracore_shared::quest::quest_progresses_for_raid(in_raid, quest_type) - }) -} - -/// The per-viewer loot-window visibility gate: is a `game_corpse_loot` row -/// visible to `viewer_guid`? A non-quest row is always visible (FFA, unconditional). A `quest_only` -/// row is visible when EITHER it's still the UNRESERVED shared row -/// (`reserved_for == 0` — nobody has split it yet) and the viewer currently needs the item -/// (`viewer_needs_item`, resolved by the caller — mirrors the module's ctx/pure split so this decision -/// needs no live cache to unit-test), OR it's the viewer's OWN already-split reserved clone -/// (`reserved_for == viewer_guid`) — a clone reserved for someone ELSE is invisible regardless of need. -/// Mirrored module-side by `loot::quest_take_allowed` (same predicate shape) so a row a viewer's -/// window shows is always a row their take can actually succeed on. Pure. +/// Quest rows use the same reservation and need rule as the Module's take Gate. pub(crate) fn quest_row_visible_to_viewer( quest_only: bool, reserved_for: u64, viewer_guid: u64, viewer_needs_item: bool, ) -> bool { - if !quest_only { - return true; - } - if reserved_for == viewer_guid { - return true; - } - reserved_for == 0 && viewer_needs_item + !quest_only + || lyracore_shared::loot::quest_take_allowed(reserved_for, viewer_guid, viewer_needs_item) } -/// The per-viewer loot-window visibility gate for a NON-quest row (the -/// caller only calls this when `!quest_only`; `quest_row_visible_to_viewer` handles quest rows, -/// unchanged, above). `reserved_for` is GENERALIZED here beyond the quest clone: nonzero also covers -/// a NEED/GREED winner's inventory-full fallback row (`resolve_roll`'s module doc) — either way, -/// nonzero means "visible ONLY to that guid", full stop. A `withheld` row (a live roll in progress) -/// is invisible to EVERYONE (not just non-eligible viewers — matches the design's "withheld rows -/// invisible in EVERYONE's window until resolved", proving the AutoLoot-safety trap: a grey + -/// green drop on one corpse has the grey's row visible/FFA while the green's is withheld). A -/// `master_only` row is never shown via the plain window (the master acts through -/// `SMSG_LOOT_MASTER_LIST`/`CMSG_LOOT_MASTER_GIVE` instead). A `designated_looter_guid` row (round- -/// robin/below-threshold) is visible only to its designee. The all-zero/false baseline is plain FFA -/// (byte-identical to the original baseline). Mirrored module-side by `loot::group_loot_take_allowed` -/// (same predicate shape) so a row a viewer's window shows is always a row their take can succeed -/// on. Pure. +/// Withheld rows stay hidden until their roll settles, including reserved rows. pub(crate) fn group_loot_row_visible_to_viewer( reserved_for: u64, withheld: bool, @@ -272,13 +218,13 @@ pub(crate) fn group_loot_row_visible_to_viewer( designated_looter_guid: u64, viewer_guid: u64, ) -> bool { - if reserved_for != 0 { - return reserved_for == viewer_guid; - } - if withheld || master_only { - return false; - } - designated_looter_guid == 0 || designated_looter_guid == viewer_guid + lyracore_shared::loot::group_loot_take_allowed( + withheld, + reserved_for, + master_only, + designated_looter_guid, + viewer_guid, + ) } #[derive(Clone, Copy)] @@ -333,45 +279,7 @@ fn loot_request_status( #[cfg(test)] mod tests { - use super::{ - group_loot_row_visible_to_viewer, needs_quest_item_pure, quest_row_visible_to_viewer, - }; - - // The per-viewer loot-window visibility gate. Every other read in this - // file goes through the coordinator's live cache (`RemoteTables`) and has no fake-cache harness to - // unit-test against (the module crate's "never mock the ctx, extract + test pure fns" rule applies - // here too). `quest_row_visible_to_viewer` and `needs_quest_item_pure` are the decisions worth - // pulling out pure so they're directly testable without a live SpacetimeDB connection. - - /// `needs_quest_item_pure` mirrors the Module's raid quest-credit rule: a Raid member's - /// non-Raid quest stops needing its item, a Raid Quest's need is unaffected, and a Party - /// member (in_raid = false) is unaffected either way. Hand-written values, not computed by - /// the code under test. - #[test] - fn needs_quest_item_pure_gates_a_raid_members_non_raid_quest() { - let normal_quest_wants_the_item = [(0, 55, 1)]; // (quest_type, target_entry, required_count) - let raid_quest_wants_the_item = [(62, 55, 1)]; - assert!( - needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 0), - "outside a Raid, a normal quest's need applies" - ); - assert!( - !needs_quest_item_pure(true, &normal_quest_wants_the_item, 55, 0), - "inside a Raid, a normal quest's item need is gated off" - ); - assert!( - needs_quest_item_pure(true, &raid_quest_wants_the_item, 55, 0), - "inside a Raid, a Raid Quest's need still applies" - ); - assert!( - !needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 1), - "held at or above the requirement never needs it" - ); - assert!( - !needs_quest_item_pure(true, &raid_quest_wants_the_item, 999, 0), - "a different item entry is never needed" - ); - } + use super::{group_loot_row_visible_to_viewer, quest_row_visible_to_viewer}; #[test] fn non_quest_rows_are_always_visible_regardless_of_viewer_state() { @@ -440,10 +348,9 @@ mod tests { } #[test] - fn group_loot_reserved_winner_locked_row_wins_over_every_other_flag() { - // A nonzero reserved_for (the inventory-full winner fallback) is exclusive and unconditional - // — it overrides withheld/master_only/designated entirely (those are all stale by then). - assert!(group_loot_row_visible_to_viewer(7, true, true, 99, 7)); + fn a_reserved_winner_waits_for_the_roll_to_release_its_row() { + assert!(!group_loot_row_visible_to_viewer(7, true, true, 99, 7)); + assert!(group_loot_row_visible_to_viewer(7, false, true, 99, 7)); assert!(!group_loot_row_visible_to_viewer(7, true, true, 99, 8)); } diff --git a/gateway/src/stdb/store/quest.rs b/gateway/src/stdb/store/quest.rs index dc2a9ffd2..b08b63029 100644 --- a/gateway/src/stdb/store/quest.rs +++ b/gateway/src/stdb/store/quest.rs @@ -240,16 +240,11 @@ pub(crate) fn quest_detail_view( ) -> Option { use crate::codec::QuestDetailView; let tmpl = db.game_quest_template().entry().find(&quest_id)?; - // MUST mirror module/src/quest.rs's award resolution exactly (explicit override → authentic - // RewMoneyMaxLevel/0.6 → xp_reward placeholder) so the completion popup equals the grant. No - // over-level penalty on either side yet (deferred — add to both together with the player's level). - let reward_xp = if tmpl.reward_xp > 0 { - tmpl.reward_xp - } else if tmpl.reward_money_max_level > 0 { - lyracore_shared::quest::quest_xp(tmpl.reward_money_max_level) - } else { - lyracore_shared::quest::xp_reward(tmpl.quest_level) - }; + let reward_xp = lyracore_shared::quest::base_reward_xp( + tmpl.reward_xp, + tmpl.reward_money_max_level, + tmpl.quest_level, + ); // Body text from the side table (cmangos Details/Objectives/OfferReward/RequestItems). let text = db.game_quest_text().quest_entry().find(&quest_id); let details = text.as_ref().map(|t| t.details.clone()).unwrap_or_default(); @@ -333,24 +328,29 @@ fn eval_relation( use crate::codec::{GiverQuestEval, ROLE_START}; let tmpl = db.game_quest_template().entry().find(&quest_entry)?; let logrow = log.iter().find(|q| q.quest_entry == quest_entry); - let active = logrow.map(|q| !q.rewarded).unwrap_or(false); - // Complete = active AND every objective met — INVENTORY-AWARE for COLLECT_ITEM (see the helper). + let active = logrow.is_some_and(|q| !q.rewarded && !q.failed); let complete = active && quest_objectives_complete(db, quest_entry, logrow, player_guid); - // A repeatable quest already rewarded keeps its CharacterQuest row (module/src/quest.rs no longer - // deletes it — deleting would erase the only record it was ever completed, breaking any OTHER quest's - // `prev_quest_id` check below), so "no row" is not the right re-offer test for it: a rewarded row on a - // repeatable quest is startable again, mirroring apply_accept_quest's duplicate-guard exception. - let no_active_or_done = - logrow.is_none() || (tmpl.repeatable && logrow.is_some_and(|q| q.rewarded)); let startable = role == ROLE_START - && no_active_or_done - && player_level >= tmpl.min_level - && lyracore_shared::quest::race_allowed(tmpl.required_races, race) - && lyracore_shared::quest::class_allowed(tmpl.required_classes, class) - && (tmpl.prev_quest_id == 0 - || log - .iter() - .any(|q| q.quest_entry == tmpl.prev_quest_id && q.rewarded)); + && lyracore_shared::quest::acceptance( + lyracore_shared::quest::AcceptanceRequirements { + min_level: tmpl.min_level, + required_races: tmpl.required_races, + required_classes: tmpl.required_classes, + repeatable: tmpl.repeatable, + }, + player_level, + race, + class, + tmpl.prev_quest_id == 0 + || log + .iter() + .any(|q| q.quest_entry == tmpl.prev_quest_id && q.rewarded), + logrow.map(|q| lyracore_shared::quest::Attempt { + rewarded: q.rewarded, + failed: q.failed, + }), + ) + .is_ok(); Some(GiverQuestEval { quest_id: tmpl.entry, title: tmpl.title.clone(), diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index 9062e791d..6e69d9f03 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -124,25 +124,29 @@ impl TrainerStore for Coordinator { .iter() .filter(|t| t.trainer_entry == trainer_entry) .collect(); - rows.sort_by_key(|t| t.spell_id); // stable order (SQL has no ORDER BY in 2.5) - // A class-spell offering id is a LearnSpell WRAPPER (1873 teaches 639); game_player_spell holds - // the RESOLVED rank, so `known` must compare the resolved id or every known spell reads Green. - // Same first-qualifying-trigger rule as resolve_learn_target (can't call it: coord() re-lock); - // one pass over the effect table for the whole list instead of a scan per row. - const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; - let wrapper_ids: std::collections::HashSet = rows + rows.sort_by_key(|t| t.spell_id); + let wrapper_ids: std::collections::HashSet<_> = rows .iter() - .filter(|t| t.learn_skill_line == 0) - .map(|t| t.spell_id) + .filter(|offering| offering.learn_skill_line == 0) + .map(|offering| offering.spell_id) .collect(); - let mut resolved: std::collections::HashMap = std::collections::HashMap::new(); - for e in db.game_spell_effect().iter() { - if wrapper_ids.contains(&e.spell_id) - && e.trigger_spell != 0 - && !EXCLUDED.contains(&e.kind) - { - resolved.entry(e.spell_id).or_insert(e.trigger_spell); - } + let mut effects: std::collections::HashMap< + u32, + Vec, + > = std::collections::HashMap::new(); + for effect in db + .game_spell_effect() + .iter() + .filter(|effect| wrapper_ids.contains(&effect.spell_id)) + { + effects.entry(effect.spell_id).or_default().push( + lyracore_shared::trainer::LearnEffect { + index: effect.effect_index, + id: effect.id, + kind: effect.kind, + trigger_spell: effect.trigger_spell, + }, + ); } Ok(rows .into_iter() @@ -151,17 +155,15 @@ impl TrainerStore for Coordinator { cost: t.cost, required_level: t.required_level, player_level: player.level, - // PROFESSION row: "known" iff the player's skill cap for this line already meets the - // offering's cap (mirrors module trainer_buy_check's gate → the client grays the tiers - // already trained past). CLASS-SPELL row (line 0): known iff a game_player_spell row - // for the RESOLVED rank behind the wrapper (mirrors the module buy gate's knows_spell). - known: if t.learn_skill_line != 0 { - skill_caps - .get(&t.learn_skill_line) - .is_some_and(|&c| c >= t.learn_skill_cap) - } else { - learned.contains(resolved.get(&t.spell_id).unwrap_or(&t.spell_id)) - }, + known: lyracore_shared::trainer::offering_known( + t.learn_skill_line, + t.learn_skill_cap, + skill_caps.get(&t.learn_skill_line).copied(), + learned.contains(&lyracore_shared::trainer::resolve_learn_target( + t.spell_id, + effects.get(&t.spell_id).into_iter().flatten().copied(), + )), + ), learn_skill_line: t.learn_skill_line, }) .collect()) @@ -197,18 +199,22 @@ impl TrainerStore for Coordinator { } fn resolve_learn_target(&self, spell_id: u32) -> u32 { - const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; let guard = self.0.coord(); - let resolved = guard - .conn - .db - .game_spell_effect() - .iter() - .filter(|e| e.spell_id == spell_id) - .find_map(|e| { - (e.trigger_spell != 0 && !EXCLUDED.contains(&e.kind)).then_some(e.trigger_spell) - }); - resolved.unwrap_or(spell_id) + lyracore_shared::trainer::resolve_learn_target( + spell_id, + guard + .conn + .db + .game_spell_effect() + .iter() + .filter(|effect| effect.spell_id == spell_id) + .map(|effect| lyracore_shared::trainer::LearnEffect { + index: effect.effect_index, + id: effect.id, + kind: effect.kind, + trigger_spell: effect.trigger_spell, + }), + ) } /// The `grant_spell_id` for `talent_id` from the coordinator's `game_talent` cache (0 = passive, @@ -244,8 +250,8 @@ impl TrainerStore for Coordinator { /// returns with the cache already consistent): `(teach_spell, superseded_prev, points_remaining)`. /// `teach_spell` = the rank-spell the module just put in the spellbook — the 1.12 TalentFrame /// derives a talent's shown rank from which rank-spell is KNOWN, so this must relay live as - /// SMSG_LEARNED_SPELL or the pane freezes until relog; 0 when the module taught nothing (mirror - /// of `talent::apply_talent_rank`'s game_spell-existence gate). `superseded_prev` = the previous + /// SMSG_LEARNED_SPELL or the pane freezes until relog; 0 when the Module spellbook has no row + /// for that rank. `superseded_prev` = the previous /// rank's now-replaced spell (drives SMSG_SUPERCEDED_SPELL; 0 for rank 1 / same-spell demo /// trees). `points_remaining` = earned (level−9, floor 0) minus spent — the live /// `PLAYER_CHARACTER_POINTS1` value. Callers may pass `talent_id = 0` to get just the points. @@ -264,8 +270,7 @@ impl TrainerStore for Coordinator { .filter(|t| t.character_guid == character_guid) .map(|t| t.rank as u32) .sum(); - let remaining = (level as i32 - 9).max(0) as u32; - let remaining = remaining.saturating_sub(spent); + let remaining = lyracore_shared::talent::points_available(level, spent); let Some(t) = db.game_talent().iter().find(|t| t.talent_id == talent_id) else { return (0, 0, remaining); }; @@ -281,10 +286,13 @@ impl TrainerStore for Coordinator { } else { 0 }; - // Mirror the module's teach gate: apply_talent_rank only puts the rank-spell in the book - // when it exists in game_spell (an unimported rank-spell is skipped there — sending - // LEARNED for it would desync the client book from the server). - let teach = if new_spell != 0 && db.game_spell().spell_id().find(&new_spell).is_some() { + // The Module's resulting spellbook row is the authority for what was taught. + let teach = if new_spell != 0 + && db + .game_player_spell() + .iter() + .any(|spell| spell.character_guid == character_guid && spell.spell_id == new_spell) + { new_spell } else { 0 @@ -402,22 +410,17 @@ impl TrainerStore for Coordinator { } } -/// Mirror of the module's `talent::pick_rank_spell`: rank N's spell from the per-rank columns -/// (`rank_spell_2..5`), a 0 column falling back to `spell_id` (the demo tree scales ONE spell by -/// rank; the imported tree has a distinct spell per rank). fn pick_rank_spell(rank: u8, t: &Talent) -> u32 { - let s = match rank { - 2 => t.rank_spell_2, - 3 => t.rank_spell_3, - 4 => t.rank_spell_4, - 5 => t.rank_spell_5, - _ => t.spell_id, - }; - if s != 0 { - s - } else { - t.spell_id - } + lyracore_shared::talent::rank_spell( + rank, + [ + t.spell_id, + t.rank_spell_2, + t.rank_spell_3, + t.rank_spell_4, + t.rank_spell_5, + ], + ) } /// The Module's typed trainer Refusal. Only a reducer the Module rejected carries a tag; a timeout, diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 976bfdfae..5213bea88 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1,38 +1,91 @@ -//! Session relay lifetime: the `PlayerSubscriptions` viewer guard and packet builders used by -//! `world_view`'s shared per-shard dispatch. -//! -//! **The shared-connection model took the four box-scoped tables out of here entirely.** Peer -//! visibility, peer movement, creature legs and gameobjects are no longer per-player registrations: -//! they ride ONE global subscription per shard on the coordinator connection, and `stdb::world_view` -//! routes each row to the sessions an in-process cell index says can see it. What is left in this -//! file for those tables is the RELAY BODIES — [`offer_peer_create_for`], [`relay_entity_update`], -//! [`relay_peer_destroy`], [`relay_gameobject_create`], [`motion_outbound`], -//! [`creature_leg_outbound`] — each of which -//! decides and encodes for exactly ONE viewer and is run on that session's own writer thread, never -//! on the shared pump. -//! -//! Owner-addressed relays use the same shape: a shard callback selects one viewer, then its writer -//! queue runs the cache reads, state updates, and packet construction. - -use crate::codec::{self, CreateKind}; -use crate::world::{CharacterStore, QuestActionStore, SessionStore, ShardRoutingStore}; +//! World Session registration, queue metrics and durable background work. + +use crate::codec; +#[cfg(test)] +use crate::codec::auction::subscriptions::auction_notice_outbound; +#[cfg(test)] +use crate::codec::combat::subscriptions::cast_event_outbound; +#[cfg(test)] +use crate::codec::combat::subscriptions::combat_event_outbound; +#[cfg(test)] +use crate::codec::combat::subscriptions::stealth_action; +#[cfg(test)] +use crate::codec::combat::subscriptions::StealthAction; +#[cfg(test)] +use crate::codec::combat::subscriptions::A_STEALTH; +use crate::codec::corpse::subscriptions::relay_corpse_create; +#[cfg(test)] +use crate::codec::duel::subscriptions::duel_event_outbound; +#[cfg(test)] +use crate::codec::entity::subscriptions::append_encounter_equip_after_create; +#[cfg(test)] +use crate::codec::entity::subscriptions::encounter_equip_outbound; +#[cfg(test)] +use crate::codec::entity::subscriptions::entity_update_to_outbound; +#[cfg(test)] +use crate::codec::entity::subscriptions::entity_update_to_outbound_with_dynamic_flags; +#[cfg(test)] +use crate::codec::entity::subscriptions::ghost_transition; +use crate::codec::entity::subscriptions::instance_relay_gate; +#[cfg(test)] +use crate::codec::entity::subscriptions::is_update_reentry; +#[cfg(test)] +use crate::codec::entity::subscriptions::peer_create_gate; +#[cfg(test)] +use crate::codec::entity::subscriptions::peer_create_outbound; +#[cfg(test)] +use crate::codec::entity::subscriptions::peer_entity_view; +#[cfg(test)] +use crate::codec::item::subscriptions::append_final_item_slots; +#[cfg(test)] +use crate::codec::item::subscriptions::enchant_change_outbound; +#[cfg(test)] +use crate::codec::item::subscriptions::trade_event_outbound; +#[cfg(test)] +use crate::codec::movement::subscriptions::append_resident_creature_after_create; +#[cfg(test)] +use crate::codec::movement::subscriptions::build_teleport_relay; +#[cfg(test)] +use crate::codec::movement::subscriptions::creature_leg_outbound; +#[cfg(test)] +use crate::codec::movement::subscriptions::motion_outbound; +#[cfg(test)] +use crate::codec::social::subscriptions::chat_event_message; +#[cfg(test)] +use crate::codec::social::subscriptions::chat_in_range; +#[cfg(test)] +use crate::codec::social::subscriptions::nearby_chat_tag; +#[cfg(test)] +use crate::codec::social::subscriptions::system_message_event_outbound; +#[cfg(test)] +use crate::codec::social::subscriptions::SAY_RANGE_SQ; +#[cfg(test)] +use crate::codec::social::subscriptions::YELL_RANGE_SQ; +#[cfg(test)] +use crate::codec::taxi::subscriptions::append_resident_taxi_after_create; +#[cfg(test)] +use crate::codec::taxi::subscriptions::taxi_spline_outbound; +#[cfg(test)] +use crate::codec::CreateKind; use crate::world::{Outbound, SessionTx}; use anyhow::{anyhow, Result}; -use lyracore_shared::item_property::client_enchantment_id; use spacetimedb_sdk::Table; use std::collections::HashSet; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -use wow_world_messages::vanilla::{AuctionHouse, Vector3d, WeatherChangeType}; +#[cfg(test)] +use wow_world_messages::vanilla::Vector3d; use super::aoi::ViewerGates; use super::bindings::*; use super::connection::Coordinator; +#[cfg(test)] use super::reads::view_of_item_row; -use super::views::{corpse_view, entity_view, go_view, hunter_pet_protocol_view}; -use super::world_index::{CellKey, EntityLayer}; +#[cfg(test)] +use super::views::entity_view; +use super::world_index::CellKey; use super::world_view::{self, Viewer, WorldView}; #[cfg(test)] @@ -142,332 +195,6 @@ impl Drop for PlayerSubscriptions { } } -/// Build the wire relay for one `game_teleport_event` row. Pure: takes the -/// already-derived `still_here` boolean (whether the live entity survived the transaction — the -/// module/gateway-mirrored same-map/cross-map signal, see the `on_teleport` callback's doc comment) -/// instead of a live `ReducerContext`/connection, so it's testable without a DB (module crate -/// convention: extract pure functions and test those). -/// -/// `still_here` → same-map: `MSG_MOVE_TELEPORT_ACK` (byte-identical to the previous behavior — the ONLY -/// caller-visible difference from before is that this is now reached via a branch rather than -/// unconditionally). NOT `still_here` → cross-map: `SMSG_TRANSFER_PENDING`+`SMSG_NEW_WORLD` as one batch -/// (`codec::build_cross_map_teleport`). `Err` only for a `map_id` the client's `Map` enum doesn't know -/// (propagated so the caller can log it — never silently drops the relay). -fn build_teleport_relay( - still_here: bool, - mover_guid: u64, - map_id: u32, - x: f32, - y: f32, - z: f32, - o: f32, -) -> Result { - if still_here { - let m = codec::build_teleport_ack(mover_guid, 0, x, y, z, o); - Ok(Outbound::One(ServerOpcodeMessage::MSG_MOVE_TELEPORT_ACK( - Box::new(m), - ))) - } else { - let [pending, new_world] = codec::build_cross_map_teleport(map_id, x, y, z, o)?; - Ok(Outbound::Batch(vec![pending, new_world])) - } -} - -/// Build the complete teleport result on the addressed session's writer queue. -pub(crate) fn teleport_event_outbound(row: &TeleportEvent) -> Vec { - match build_teleport_relay( - !row.cross_map, - row.mover_guid, - row.map_id, - row.x, - row.y, - row.z, - row.orientation, - ) { - Ok(out) => vec![out], - Err(error) => { - log::warn!("teleport relay: {error} (guid {})", row.mover_guid); - Vec::new() - } - } -} - -/// Build one addon-language whisper using the addressed viewer as its sender. -pub(crate) fn addon_message_outbound(self_guid: u64, row: &AddonMessage) -> Vec { - let text = codec::addon::build_bridge_envelope(&row.cmd, &row.payload); - let (opcode, body) = codec::addon::build_addon_smsg_raw(self_guid, &text); - vec![Outbound::Raw { opcode, body }] -} - -/// Build the owner's Hunter pet descriptor VALUES for one owner-addressed row. -pub(crate) fn hunter_pet_outbound(row: &HunterPetProtocol) -> Vec { - vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_hunter_pet_values(&hunter_pet_protocol_view( - row.clone(), - ))), - ))] -} - -/// Build the XP-gain result for one identity-addressed row. -pub(crate) fn xp_event_outbound(row: &XpEvent) -> Vec { - let (opcode, body) = codec::build_log_xpgain_raw( - row.killed_guid, - row.total_exp, - row.is_kill, - row.rested_bonus, - ); - vec![Outbound::Raw { opcode, body }] -} - -/// Build the level-up result for one identity-addressed row. -pub(crate) fn levelup_event_outbound(row: &LevelupEvent) -> Vec { - let message = codec::build_levelup_info( - row.new_level, - row.health_gained, - row.mana_gained, - row.strength_gained, - row.agility_gained, - row.stamina_gained, - row.intellect_gained, - row.spirit_gained, - ); - vec![Outbound::One(ServerOpcodeMessage::SMSG_LEVELUP_INFO( - Box::new(message), - ))] -} - -/// Stealth presence marker (taxonomy `A_STEALTH`): a unit carrying ≥1 such aura is stealthed and must -/// be hidden from OTHER players' clients. Mirrors `module::spell::taxonomy::A_STEALTH = 0xA8`. -pub(crate) const A_STEALTH: u8 = 0xA8; - -/// The visibility action a single A_STEALTH aura insert/delete implies, decided from the POST-change -/// count of A_STEALTH auras on the stealther's guid. A Stealth cast applies TWO A_STEALTH auras (and a -/// break removes both); since the SDK applies the whole row-delta before firing callbacks, both inserts -/// fire seeing count 2 and both deletes seeing count 0 — so we HIDE on ANY insert while stealthed -/// (count >= 1) and REVEAL when the count reaches 0. Pure + unit-tested; the exactly-once guarantee -/// against an already-hidden/visible peer is the per-viewer `created` set at the call site, not a boundary. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum StealthAction { - Hide, - Reveal, - None, -} - -/// Hide on a stealth insert and reveal when the last marker is removed. The aura index supplies -/// the count after this callback's change; the viewer's `created` set makes repeated actions harmless. -pub(crate) fn stealth_action(is_insert: bool, post_change_count: usize) -> StealthAction { - match (is_insert, post_change_count) { - (true, n) if n >= 1 => StealthAction::Hide, - (false, 0) => StealthAction::Reveal, - _ => StealthAction::None, - } -} - -/// Read a peer's equipment and current aura state for AOI entry or stealth reveal. -/// An encoding failure leaves the caller responsible for removing its `created` entry. -fn build_peer_create( - coord: &Coordinator, - world: &WorldView, - shard: super::world_index::ShardId, - viewer_guid: u64, - row: &WorldEntity, -) -> Option> { - let inv: Vec = - if row.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 { - coord - .player_items(row.guid) - .unwrap_or_default() - .into_iter() - .filter(|i| i.slot <= 18) - .collect() - } else { - Vec::new() - }; - let view = peer_entity_view( - row, - projected_dynamic_flags(&coord.0.coord().conn.db, viewer_guid, row), - |guid| guild_projection_of(coord, guid), - ); - let auras = world.auras.on_target(shard, row.guid); - let movement = coord - .0 - .coord() - .conn - .db - .game_entity_motion() - .guid() - .find(&row.guid) - .filter(|motion| motion.map_id == row.map_id && motion.instance_id == row.instance_id) - .filter(|motion| { - codec::bytes_to_movement_info(&motion.movement_info).is_ok_and(|info| { - info.timestamp == row.last_move_ms - && info.position.x == row.x - && info.position.y == row.y - && info.position.z == row.z - && info.orientation == row.orientation - }) - }); - match peer_create_outbound( - &view, - &inv, - &auras, - movement - .as_ref() - .map(|motion| motion.movement_info.as_slice()), - ) { - Ok(out) => Some(out), - Err(e) => { - log::warn!("peer create encode failed for guid {}: {e}", row.guid); - None - } - } -} - -/// The view a peer CREATE encodes: the stored row, the viewer-relative dynamic flags, and for a -/// player the Guild Projection `guild_of` reads. -fn peer_entity_view( - row: &WorldEntity, - dynamic_flags: u32, - guild_of: impl FnOnce(u64) -> (u32, u32), -) -> codec::EntityView { - let mut view = entity_view(row.clone(), 0); - view.dynamic_flags = dynamic_flags; - if row.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 { - (view.guild_id, view.guild_rank) = guild_of(row.guid); - } - view -} - -/// A peer's current auras follow its CREATE. Their rows may have arrived before the viewer -/// could see it, so another aura callback cannot be relied on to supply them. -fn peer_create_outbound( - view: &codec::EntityView, - inventory: &[codec::ItemInstanceView], - auras: &[Aura], - movement: Option<&[u8]>, -) -> Result> { - let create = codec::build_create_object(view, CreateKind::Peer, inventory, &[])?; - let created = match movement { - Some(movement) => Outbound::Raw { - opcode: 0x00a9, - body: codec::create_with_movement(&create, movement)?, - }, - None => Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(create))), - }; - let mut out = vec![created]; - if !auras.is_empty() { - out.push(aura_sync(auras.iter().cloned(), view.guid)); - } - Ok(out) -} - -struct LootTagProjectionRows<'a> { - tap: Option<&'a CreatureQuestTap>, - tag_group: Option<&'a CreatureLootTagGroup>, - tag_members: &'a [CreatureQuestTapMember], - current_members: &'a [GroupMember], - corpse_eligible: &'a [CorpseLootEligible], -} - -/// Render the stored entity flags for one viewer without changing the durable row. -fn viewer_relative_dynamic_flags( - viewer_guid: u64, - entity: &WorldEntity, - rows: LootTagProjectionRows<'_>, -) -> u32 { - use lyracore_shared::constants::unit_dynamic_flags::{LOOTABLE, TAPPED_BY_PLAYER}; - - if entity.dead { - return if rows - .corpse_eligible - .iter() - .any(|row| row.corpse_guid == entity.guid && row.eligible_guid == viewer_guid) - { - entity.dynamic_flags - } else { - entity.dynamic_flags & !LOOTABLE - }; - } - - let Some(tap) = rows.tap.filter(|tap| tap.creature_guid == entity.guid) else { - return entity.dynamic_flags; - }; - let entitled = match rows - .tag_group - .filter(|group| group.creature_guid == entity.guid) - { - Some(group) => { - rows.tag_members.iter().any(|member| { - member.creature_guid == entity.guid && member.character_guid == viewer_guid - }) && rows.current_members.iter().any(|member| { - member.character_guid == viewer_guid && member.group_id == group.group_id - }) - } - None => viewer_guid == tap.character_guid, - }; - if entitled { - entity.dynamic_flags | TAPPED_BY_PLAYER - } else { - entity.dynamic_flags & !TAPPED_BY_PLAYER - } -} - -fn projected_dynamic_flags(db: &RemoteTables, viewer_guid: u64, entity: &WorldEntity) -> u32 { - let tap = db - .game_creature_quest_tap() - .creature_guid() - .find(&entity.guid); - let tag_group = db - .game_creature_loot_tag_group() - .creature_guid() - .find(&entity.guid); - let tag_members: Vec<_> = db - .game_creature_quest_tap_member() - .iter() - .filter(|member| member.creature_guid == entity.guid) - .collect(); - let current_members: Vec<_> = db - .game_group_member() - .iter() - .filter(|member| member.character_guid == viewer_guid) - .collect(); - let corpse_eligible: Vec<_> = db - .game_corpse_loot_eligible() - .iter() - .filter(|eligible| eligible.corpse_guid == entity.guid) - .collect(); - viewer_relative_dynamic_flags( - viewer_guid, - entity, - LootTagProjectionRows { - tap: tap.as_ref(), - tag_group: tag_group.as_ref(), - tag_members: &tag_members, - current_members: ¤t_members, - corpse_eligible: &corpse_eligible, - }, - ) -} - -/// May an instance-tagged CORPSE/GAMEOBJECT row be CREATE-relayed to this -/// viewer? Instance equality against a KNOWN viewer instance — `None` (viewer entity not resident, -/// see `viewer_instance`) relays nothing: suppress-then-sweep is strictly safer than the old -/// default-to-0 (which leaked instance-0 rows to a viewer whose real instance wasn't known yet). -/// Kept map-agnostic deliberately: instance-0 rows keep today's relay behavior byte-identical for -/// every resident open-world viewer, while a per-instance copy (or an in-instance corpse) reaches -/// ONLY viewers inside that instance. DELETE relays stay ungated (the `on_melee_delete` precedent — -/// SMSG_DESTROY_OBJECT for a never-created guid is a client no-op, and gating deletes risks -/// leaking a stale object on an instance transition). Pure — unit-tested below. -pub(crate) fn instance_relay_gate(row_instance_id: u64, viewer_instance_id: Option) -> bool { - viewer_instance_id == Some(row_instance_id) -} - -/// One corpse CREATE relay — the shared body of the `game_corpse` on_insert callback AND the -/// post-AOI resident sweep: with `LYRACORE_AOI=1` the base sub's corpse -/// callbacks fire before the viewer's entity is resident, so the gate suppresses them (see -/// `viewer_instance`); once the AOI entity subscription has applied, the sweep re-offers every -/// resident corpse row through the same gate. Sends the CREATE plus, for the viewer's OWN corpse, -/// the reclaim-delay packet. /// Diagnosis counters for a failure where the peer-movement relay died permanently on a session — /// the motion subscription stopped delivering while the module kept accepting movement. Deliberately /// atomics read ONCE at logout rather than a log per call: a @@ -560,3230 +287,133 @@ pub(crate) const FANOUT_REBASELINE_WINDOWS: u32 = 6; /// fan-out within two windows and the run would never reach [`FANOUT_COLLAPSE_WINDOWS`]. pub(crate) const FANOUT_BASELINE_ALPHA: f64 = 0.3; -/// Trailing fan-out baseline + the length of the current run of collapsed windows. Lives in the -/// 10-second `MOTIONSTAT` task; nothing on the relay hot path touches it. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub(crate) struct FanoutHealth { - /// `None` until the first informative window establishes it. - pub(crate) baseline: Option, - pub(crate) low_windows: u32, -} - -/// One 10-second window of the fan-out health check — pure, so the decision is unit-tested directly -/// instead of being eyeballed in a log. Returns the next state, and -/// `Some(consecutive_collapsed_windows)` when this window should warn (`None` = stay quiet). -/// -/// **What it catches.** Peer movement being delivered to fewer observers than it was a moment ago, -/// while players are still moving — the 371-client shape, where nothing errored, nothing logged, and -/// peers simply updated less often than they moved. `delivery` (= sent/calls) read ~100 % throughout -/// that run, because the shortfall was `calls` never reaching the rate it should have; `fanout` is -/// the field that showed it, and this turns that field from "a number an operator has to know the -/// expected value of" into a warning. -/// -/// **What it does NOT catch — read this before trusting it.** -/// * **A gateway that has been degraded since startup.** The baseline is the gateway's OWN recent -/// behaviour, so if fan-out was already wrong when the first window was measured, that wrong value -/// IS the baseline and nothing ever trips. This check reports a CHANGE, never an absolute truth. -/// Nothing in the gateway currently knows how many observers a mover *should* have reached — -/// the AOI box is per-session, invisible to this task, and `GridBox` has no -/// overlap test, so a true expectation needs a global registry of per-session boxes plus box -/// intersection. That is the honest version of this warning and it is not what this is. -/// * **A slow slide.** A degradation that takes minutes to develop keeps each window within -/// [`FANOUT_COLLAPSE_RATIO`] of the last, and the baseline follows it down. Only a step change of -/// ≥40 % inside ~30 s trips. -/// * **A collapse that starts during a quiet spell.** Windows below -/// [`crate::world::MOVE_ACTIVITY_FLOOR`] submitted movements are treated as uninformative: they do -/// not warn, and they break a run in progress. -/// * **Under-delivery to SOME sessions.** Both counters are process-global, so one wedged session -/// among 300 healthy ones is invisible here. -/// -/// A TOTAL loss of the relay trips this as well as the stuck-relay signal (fan-out goes to ~0, which is -/// certainly below any baseline) — two lines about one failure, 30 s apart, which is the right way -/// round: the stuck-relay signal is instant and keeps repeating, this one adds "and it stayed that way". -pub(crate) fn fanout_health_step( - state: FanoutHealth, - fanout: Option, - submitted_delta: u64, -) -> (FanoutHealth, Option) { - // Uninformative window: too little movement to compare, or none at all. Keep the baseline (a - // quiet window is not evidence about fan-out) but break any run in progress — the same activity - // floor the stuck-relay signal uses, for the same reason: a handful of movements with little relay - // traffic is one lonely player, not a broken relay. - let Some(f) = fanout.filter(|_| submitted_delta > crate::world::MOVE_ACTIVITY_FLOOR) else { - return ( - FanoutHealth { - low_windows: 0, - ..state - }, - None, - ); - }; - let Some(baseline) = state.baseline else { - // First informative window: nothing to compare against yet. - return ( - FanoutHealth { - baseline: Some(f), - low_windows: 0, - }, - None, - ); - }; - if f >= baseline * FANOUT_COLLAPSE_RATIO { - // Healthy window — fold it into the trailing baseline and forget any partial run. - let next = baseline + FANOUT_BASELINE_ALPHA * (f - baseline); - return ( - FanoutHealth { - baseline: Some(next), - low_windows: 0, - }, - None, - ); - } - let low_windows = state.low_windows + 1; - let warn = (low_windows >= FANOUT_COLLAPSE_WINDOWS).then_some(low_windows); - if low_windows >= FANOUT_REBASELINE_WINDOWS { - // Warned for ~30 s and it is still low: accept this as the new normal (see - // FANOUT_REBASELINE_WINDOWS) — this window still warns, the next one will not. - return ( - FanoutHealth { - baseline: Some(f), - low_windows: 0, - }, - warn, - ); - } - ( - FanoutHealth { - baseline: Some(baseline), - low_windows, - }, - warn, - ) -} - -/// **Should this peer-motion packet be dropped instead of queued?** The one decision behind the -/// per-session egress bound (task B2) — pure and unit-tested, called with `tx.depth()`. -/// -/// The channel behind [`crate::world::SessionTx`] is unbounded, so a session whose socket has -/// stopped taking bytes grows its backlog for as long as the stall lasts: a memory path under crowd -/// load, and the reason `MOTION_DROPPED` could not mean backpressure before this. Bounding the -/// CHANNEL is not available to us — ~110 of the send sites run inside SpacetimeDB SDK row callbacks -/// on the connection's pump thread, and a blocking send from there on a full channel would deadlock -/// that connection (see `SessionTx`'s own comment). So the queue stays unbounded and the only -/// traffic that can plausibly outrun a socket sheds itself. -/// -/// **Why only peer motion and creature legs, out of ~180 send sites.** They are the N²-shaped -/// traffic: one packet per OBSERVER per mover per heartbeat, ~740/s per session at 371 co-located -/// clients, versus a handful per player action for everything else. And they are the only sends that -/// are SELF-HEALING when lost — a movement heartbeat is superseded by the next one 500 ms later, so -/// a shed packet costs a moment of coarser interpolation on a peer that is already being rendered -/// from stale data (the queue is half a second deep). A quest-log page, an item CREATE, a -/// `SMSG_DESTROY_OBJECT` is not superseded by anything: dropping one leaves the client permanently -/// wrong. Those keep queueing exactly as before. -/// -/// Shedding is counted in [`MOTION_DROPPED`], so it is visible on the existing `MOTIONSTAT` line as -/// both a rising `dropped` and a falling `delivery` — a shed that fires is not silent. -pub(crate) fn shed_motion_at_depth(depth: usize) -> bool { - depth > crate::world::EGRESS_SHED_DEPTH -} - -fn relay_corpse_create(tx: &SessionTx, self_guid: u64, row: &Corpse) { - for o in corpse_create_outbound(self_guid, row) { - let _ = tx.send(o); - } -} - -/// The corpse CREATE body as outbound packets — shared by the per-player relay, the login -/// resident sweep, and the shared-dispatch twin. The owner's reclaim-delay -/// packet rides inside, keyed on the VIEWER's guid. -pub(crate) fn corpse_create_outbound(self_guid: u64, row: &Corpse) -> Vec { - let m = codec::build_corpse_create_object(&corpse_view(row.clone())); - let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(m), - ))]; - if row.owner_guid == self_guid { - out.push(Outbound::One( - ServerOpcodeMessage::SMSG_CORPSE_RECLAIM_DELAY( - wow_world_messages::vanilla::SMSG_CORPSE_RECLAIM_DELAY { - delay: Duration::from_micros(row.reclaim_delay_micros as u64), - }, - ), - )); - } - out -} - -/// The shared-dispatch twin of the per-player `on_corpse_update` relay (corpses): the body→bones -/// re-CREATE. The instance gate already ran in `world_view::corpse_changed`. -pub(crate) fn relay_corpse_update(row: &Corpse) -> Vec { - let m = codec::build_corpse_create_object(&corpse_view(row.clone())); - vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(m), - ))] -} - -fn peer_create_gate( - row_guid: u64, - self_guid: u64, - row_instance_id: u64, - viewer_instance_id: u64, - row_npc_flags: u32, - viewer_is_ghost: bool, - row_is_stealthed: bool, -) -> bool { - const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 - if row_guid == self_guid { - return false; - } - if row_instance_id != viewer_instance_id { - return false; - } - if row_npc_flags & SPIRITHEALER_NPC_FLAG != 0 && !viewer_is_ghost { - return false; - } - if row_is_stealthed { - return false; - } - true -} - -/// SAY proximity range (vanilla ~25yd). See [`chat_in_range`]. -const SAY_RANGE_YD: f32 = 25.0; -/// YELL proximity range (vanilla ~300yd). Same rationale as `SAY_RANGE_YD`. -const YELL_RANGE_YD: f32 = 300.0; -#[cfg(test)] -const SAY_RANGE_SQ: f32 = SAY_RANGE_YD * SAY_RANGE_YD; // 625.0 yd² -#[cfg(test)] -const YELL_RANGE_SQ: f32 = YELL_RANGE_YD * YELL_RANGE_YD; // 90_000.0 yd² - -/// How far a `game_chat_event` line of this kind carries. Every kind on that table is -/// range-bound: SAY, `/e` and the creature text emote at SAY range, YELL at its own; party, guild -/// and whispers ride other tables. -pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { - if chat_type == lyracore_shared::chat::broadcast_chat::YELL { - YELL_RANGE_YD - } else { - SAY_RANGE_YD - } -} - -// ================================================================================================== -// The SHARED-dispatch relay bodies. -// -// Each of these is one audience-of-one relay: `stdb::world_view` decides WHICH sessions a row -// concerns (via the cell index) and enqueues one of these per session as an `Outbound::Job`, so -// the gating + encoding below runs on that session's own writer thread rather than on the shard's -// single coordinator pump. They are the same bodies the per-player callbacks ran; what changed is -// where the viewer-owned state comes from — a `Viewer` handle instead of a closure capture — and -// that the two facts an away connection could not read (the viewer's instance and ghost flag) are -// now supplied rather than looked up, which is what let the away tier be deleted. -// ================================================================================================== - -/// The full gated "show this row to the viewer" path — [`peer_create_gate`], then the per-viewer -/// `created` dedup, then the CREATE send. Shared by the insert dispatch, the update re-entry branch, -/// the recenter's entered-set, and the world-entry sweep. -/// -/// On a `build_peer_create` encode failure the guid is ROLLED BACK out of `created`: leaving it in -/// permanently suppresses the peer (a second latent bug in the same re-entry path, below). -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn offer_peer_create_for( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - viewer: &Viewer, - row: &WorldEntity, -) -> Vec { - const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 - if row.guid == viewer.self_guid || row.instance_id != viewer.instance_id { - return Vec::new(); - } - let viewer_is_ghost = viewer.gates.is_ghost(); - // A spirit healer viewed by a living player is refused regardless of stealth. - if row.npc_flags & SPIRITHEALER_NPC_FLAG != 0 && !viewer_is_ghost { - return Vec::new(); - } - let row_is_stealthed = view.auras.is_stealthed(shard, row.guid); - let guard = coord.0.coord(); - let db = &guard.conn.db; - if !peer_create_gate( - row.guid, - viewer.self_guid, - row.instance_id, - viewer.instance_id, - row.npc_flags, - viewer_is_ghost, - row_is_stealthed, - ) { - return Vec::new(); - } - if !viewer.created.lock().unwrap().insert(row.guid) { - return Vec::new(); - } - let Some(mut out) = build_peer_create(coord, view, shard, viewer.self_guid, row) else { - viewer.created.lock().unwrap().remove(&row.guid); - return Vec::new(); - }; - if let Some(equipment) = db.game_encounter_equip().creature_guid().find(&row.guid) { - append_encounter_equip_after_create(&mut out, viewer, &equipment); - } - // Pet action bar: this viewer's OWN summoned pet just appeared — bind it client-side - // (UNIT_FIELD_SUMMON + the pet action bar). - if row.owner_guid == viewer.self_guid { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_owner_summon_values(viewer.self_guid, row.guid)), - ))); - if let Some(pet) = db - .game_hunter_pet_protocol() - .iter() - .find(|pet| pet.live_pet_guid == row.guid && pet.owner_guid == viewer.self_guid) - { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_hunter_pet_values(&hunter_pet_protocol_view( - pet, - ))), - ))); - } - let spells: Vec = db - .game_creature_cast() - .iter() - .filter(|c| c.creature_entry == row.entry) - .map(|c| c.spell_id) - .collect(); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_PET_SPELLS( - Box::new(codec::build_pet_spells(row.guid, &spells)), - ))); - } - let resident_creature_spline = db.game_creature_spline().guid().find(&row.guid); - append_resident_creature_after_create( - &mut out, - &viewer.created, - viewer.instance_id, - resident_creature_spline.as_ref(), - unix_now_micros(), - ); - if let Some(spline) = db - .game_taxi_passenger_spline() - .character_guid() - .find(&row.guid) - { - append_resident_taxi_after_create(&mut out, &viewer.created, viewer.self_guid, &spline); - } - out -} - -/// The Guild Projection of `character_guid`, read from the Realm-core cache. `(0, 0)` when -/// Realm-core cannot answer: the next membership relay corrects it. -fn guild_projection_of(coord: &Coordinator, character_guid: u64) -> (u32, u32) { - coord - .realm_core() - .map_or((0, 0), |realm| realm.guild_projection(character_guid)) -} - -/// Relay the current Guild Projection of `character_guid` to a viewer that holds its entity: the -/// member itself or a viewer whose `created` set has the guid. `projection` is read when the job -/// runs, so a delayed job never writes a value older than the cache. -pub(crate) fn guild_values_outbound( - viewer: &Viewer, - character_guid: u64, - projection: (u32, u32), -) -> Vec { - if viewer.self_guid != character_guid - && !viewer.created.lock().unwrap().contains(&character_guid) - { - return Vec::new(); - } - let (guild_id, rank_id) = projection; - let (opcode, body) = codec::build_guild_values(character_guid, guild_id, rank_id); - vec![Outbound::Raw { opcode, body }] -} - -/// Render one Guild Event row. The row carries its final strings. Kinds 0x40 (INVITE) and 0x41 -/// (DECLINE) are membership offers with their own wire shape; every other kind not built from a -/// shared snapshot (see `GuildEventSnapshot`) rides the generic SMSG_GUILD_EVENT builder. -pub(crate) fn guild_event_outbound(row: &GuildEvent) -> Vec { - match row.kind { - lyracore_shared::guild::event_kind::INVITE => { - let actor_name = row.strings.first().cloned().unwrap_or_default(); - let guild_name = row.strings.get(1).cloned().unwrap_or_default(); - vec![Outbound::One(ServerOpcodeMessage::SMSG_GUILD_INVITE( - Box::new(codec::build_guild_invite(actor_name, guild_name)), - ))] - } - lyracore_shared::guild::event_kind::DECLINE => { - let actor_name = row.strings.first().cloned().unwrap_or_default(); - let (opcode, body) = codec::build_guild_decline_raw(&actor_name); - vec![Outbound::Raw { opcode, body }] - } - _ => { - let (opcode, body) = - codec::build_guild_event_raw(row.kind, &row.strings, row.subject_guid); - vec![Outbound::Raw { opcode, body }] - } - } -} - -/// Render one petition Guild Event (`0x70..=0x7F`). The Charter is in `other_guid` and the -/// signer or decliner in `subject_guid`. `petition` is the Petition of that Charter, read when the -/// job runs; only PETITION_OFFERED and PETITION_CHANGED need it, and a Petition that is gone by -/// then renders nothing. -pub(crate) fn petition_event_outbound( - row: &GuildEvent, - petition: Option<&codec::PetitionView>, -) -> Vec { - use lyracore_shared::guild::event_kind; - use wow_world_messages::vanilla::{ - GuildCommand, GuildCommandResult, PetitionResult, MSG_PETITION_DECLINE, - }; - let sign_results = |result| { - vec![Outbound::One( - ServerOpcodeMessage::SMSG_PETITION_SIGN_RESULTS(Box::new( - codec::build_petition_sign_results(row.other_guid, row.subject_guid, result), - )), - )] - }; - match row.kind { - event_kind::PETITION_OFFERED => petition - .map(|petition| { - Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES( - Box::new(codec::build_petition_show_signatures(petition)), - )) - }) - .into_iter() - .collect(), - event_kind::PETITION_SIGNED => sign_results(PetitionResult::Ok), - event_kind::PETITION_ALREADY_SIGNED => sign_results(PetitionResult::AlreadySigned), - event_kind::PETITION_DECLINED => vec![Outbound::One( - ServerOpcodeMessage::MSG_PETITION_DECLINE(MSG_PETITION_DECLINE { - petition: wow_world_messages::Guid::new(row.subject_guid), - }), - )], - // Result 0 is success; gtker names the zero value PLAYER_NO_MORE_IN_GUILD. - event_kind::FOUNDER => vec![Outbound::One( - ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(Box::new( - codec::build_guild_command_result( - GuildCommand::Founder, - row.strings.first().cloned().unwrap_or_default(), - GuildCommandResult::PlayerNoMoreInGuild, - ), - )), - )], - event_kind::PETITION_CHANGED => petition - .map(|petition| { - Outbound::One(ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(Box::new( - codec::build_petition_query_response(petition), - ))) - }) - .into_iter() - .collect(), - _ => Vec::new(), - } -} - -/// Relay one durable creature virtual-item projection to a viewer that already holds the creature. -/// A delete clears all three display slots. The raw builder is the crash-safe sparse VALUES path. -pub(crate) fn encounter_equip_outbound( - viewer: &Viewer, - row: &EncounterEquip, - cleared: bool, -) -> Vec { - if !viewer.created.lock().unwrap().contains(&row.creature_guid) { - return Vec::new(); - } - let (main_hand, off_hand, ranged) = if cleared { - (0, 0, 0) - } else { - (row.main_hand, row.off_hand, row.ranged) - }; - let (opcode, body) = - codec::build_virtual_item_values(row.creature_guid, main_hand, off_hand, ranged); - vec![Outbound::Raw { opcode, body }] -} - -fn append_encounter_equip_after_create( - out: &mut Vec, - viewer: &Viewer, - row: &EncounterEquip, -) { - out.extend(encounter_equip_outbound(viewer, row, false)); -} - -/// One entity row changed, for one viewer: re-entry-as-UPDATE first (a peer that -/// left the box and returns arrives as an update of a still-cached row, and without this branch it -/// is permanently invisible until relog), otherwise the pure field diff, plus the viewer's own -/// spirit-healer ghost transition. -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn relay_entity_update( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - viewer: &Viewer, - old: &WorldEntity, - new: &WorldEntity, -) -> Vec { - const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; - const GHOST_PLAYER_FLAG: u32 = lyracore_shared::constants::player_flags::GHOST; - if new.guid != viewer.self_guid { - let shown = viewer.created.lock().unwrap().contains(&new.guid); - if is_update_reentry(new.guid, viewer.self_guid, shown) { - return offer_peer_create_for(coord, view, shard, viewer, new); - } - } - let dynamic_flags = { - let guard = coord.0.coord(); - projected_dynamic_flags(&guard.conn.db, viewer.self_guid, new) - }; - let mut out: Vec = - entity_update_to_outbound_with_dynamic_flags(old, new, dynamic_flags) - .into_iter() - .map(Outbound::One) - .collect(); - if new.guid == viewer.self_guid { - if let Some(is_ghost) = - ghost_transition(old.player_flags, new.player_flags, GHOST_PLAYER_FLAG) - { - viewer - .gates - .is_ghost - .store(is_ghost, std::sync::atomic::Ordering::Relaxed); - // Every spirit healer currently IN VIEW (the index answers that; before the - // shared-connection model this scanned the AOI-scoped connection cache, which was the - // same set). Idempotent through - // `created`, exactly like the stealth Reveal/Hide arms. - let guard = coord.0.coord(); - let healers: Vec = guard - .conn - .db - .game_world_entity() - .iter() - .filter(|e| { - e.npc_flags & SPIRITHEALER_NPC_FLAG != 0 && e.instance_id == new.instance_id - }) - .collect(); - drop(guard); - for h in healers { - if is_ghost { - out.extend(offer_peer_create_for(coord, view, shard, viewer, &h)); - } else if viewer.created.lock().unwrap().remove(&h.guid) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(h.guid), - ))); - } - } - } - } - out -} - -/// Re-send the projected flags for visible live Loot Tags after the shard membership mirror -/// changes. Membership does not rewrite a creature's `WorldEntity`, so its normal VALUES relay has -/// nothing to carry. -pub(crate) fn loot_tag_flags_after_membership_change( - coord: &Coordinator, - viewer: &Viewer, -) -> Vec { - use lyracore_shared::constants::unit_dynamic_flags::TAPPED; - - let shown = viewer.created.lock().unwrap().clone(); - let guard = coord.0.coord(); - shown - .iter() - .filter_map(|guid| guard.conn.db.game_world_entity().guid().find(guid)) - .filter(|entity| !entity.dead && entity.dynamic_flags & TAPPED != 0) - .map(|entity| { - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_dynamic_flags_values( - entity.guid, - projected_dynamic_flags(&guard.conn.db, viewer.self_guid, &entity), - ), - ))) - }) - .collect() -} - -/// A peer left this viewer's view (its row was deleted, or it walked out of the box). DESTROY once, -/// and only if the viewer had actually been shown it. -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn relay_peer_destroy(viewer: &Viewer, guid: u64, owner_guid: u64) -> Vec { - if guid == viewer.self_guid { - return Vec::new(); - } - if !viewer.created.lock().unwrap().remove(&guid) { - return Vec::new(); - } - let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(guid), - ))]; - // Pet action bar: this viewer's own pet despawned — clear UNIT_FIELD_SUMMON and send the empty - // SMSG_PET_SPELLS (the vanilla "remove pet bar" form). - if owner_guid == viewer.self_guid { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_owner_summon_values(viewer.self_guid, 0)), - ))); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_PET_SPELLS( - Box::new(codec::build_pet_spells_clear()), - ))); - } - out -} - -/// A gameobject entering (or changing state within) this viewer's view → CREATE_OBJECT, joined to -/// its static template on the holding shard's coordinator cache. -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn relay_gameobject_create( - coord: &Coordinator, - viewer: &Viewer, - row: &GameObject, -) -> Vec { - if !instance_relay_gate(row.instance_id, Some(viewer.instance_id)) { - return Vec::new(); - } - let guard = coord.0.coord(); - let Some(tmpl) = guard - .conn - .db - .game_gameobject_template() - .entry() - .find(&row.template_entry) - else { - return Vec::new(); - }; - let view = go_view(row.clone(), &tmpl); - // The CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn - // quaternion (gtker's slot-0-only wall), so the other 3 slots — and the yaw-derive fallback for - // an all-zero stored quaternion — ride a second raw VALUES frame right behind CREATE. The client - // has already materialized the object by the time this second frame arrives (same ordering the - // aura/quest-log raw-VALUES relays rely on elsewhere in this file). - let (rot_opcode, rot_body) = codec::build_gameobject_rotation_values(&view); - vec![ - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_gameobject_create_object(&view), - ))), - Outbound::Raw { - opcode: rot_opcode, - body: rot_body, - }, - ] -} - -/// A gameobject leaving view. Ungated on purpose (the `on_melee_delete` precedent): DESTROY for a -/// guid the client never created is a client no-op, and gating it risks a stale prop. -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn relay_gameobject_destroy(_viewer: &Viewer, guid: u64) -> Vec { - vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(guid), - ))] -} - -/// Encode one zone's sky. The audience decision (the viewer's stored zone) already happened in -/// `world_view::weather_changed`, and the changed row carried the whole packet, so nothing is read -/// here. -pub(crate) fn weather_outbound( - view: codec::ZoneWeatherView, - change: WeatherChangeType, -) -> Vec { - vec![Outbound::One(ServerOpcodeMessage::SMSG_WEATHER(Box::new( - codec::build_weather(view, change), - )))] -} - -/// Read `zone_id`'s current sky off this shard's cache and encode it — the zone-entry leg, run on -/// the arriving viewer's own writer thread. Goes through the same `WeatherStore` seam world entry -/// uses, so a zone with no row means fine weather here too. -pub(crate) fn zone_weather_outbound( - store: &St, - zone_id: u32, - change: WeatherChangeType, -) -> Vec { - vec![Outbound::One(crate::world::zone_weather_message( - store, zone_id, change, - ))] -} - -/// The shared-dispatch twin of the per-player -/// `on_rest_insert` relay (rest state). Same raw PLAYER_BYTES_2 VALUES packet; the audience (owner -/// only) was already resolved by the owner-session lookup in `world_view::rest_state_appeared`. -pub(crate) fn relay_rest_state(self_guid: u64, player_bytes_2: u32) -> Vec { - let (opcode, body) = codec::build_rest_state_values(self_guid, player_bytes_2); - vec![Outbound::Raw { opcode, body }] -} - -/// Shared-dispatch recipient leg for a breath relay row. The owner-session lookup in -/// `world_view::breath_relay_appeared` has already established the self-only audience. -pub(crate) fn relay_breath_event( - character_guid: u64, - kind: u8, - time_remaining_ms: u32, - duration_ms: u32, - damage: u32, -) -> Vec { - let msg = match kind { - 0 => codec::build_breath_timer_start(time_remaining_ms, duration_ms), - 1 => codec::build_breath_timer_stop(), - 2 => codec::build_drowning_damage_log(character_guid, damage), - unknown => { - log::warn!("breath relay: dropping unknown kind {unknown} for {character_guid}"); - return Vec::new(); - } - }; - vec![Outbound::One(msg)] -} - -/// Relay a visible melee swing or ranged launch. The Module emits ranged damage at impact -/// through `SpellCastEvent`, which `cast_event_outbound` relays separately. -pub(crate) fn combat_event_outbound( - created: &Mutex>, - row: &CombatEvent, -) -> Vec { - if !created.lock().unwrap().contains(&row.attacker_guid) { - return Vec::new(); - } - let mut out = Vec::new(); - // A ranged launch uses SPELL_GO. ATTACKERSTATEUPDATE would animate a melee swing. - if row.ranged_spell_id != 0 { - // Auto Shot stamps an ammo display id → the AMMO flag fires the arrow graphic (24 = - // INVTYPE_AMMO). Wand Shoot (ammo_display_id 0) → no flag → the bolt. Deliberate - // simplification: inv-type 24 is hardcoded. - let ammo = if row.ammo_display_id != 0 { - Some((row.ammo_display_id, 24)) - } else { - None - }; - let miss = row.hit_info == 2; // module HIT_MISS - let go = codec::build_spell_go_outcome( - row.attacker_guid, - row.ranged_spell_id, - row.target_guid, - ammo, - miss, - ); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(Box::new( - go, - )))); - } else if !row.spell_swing { - // A fired on-next-swing spell (Heroic Strike/Cleave) REPLACES the white hit — the - // whole swing rides the spell's cast-event row (GO + yellow named damage log), so this - // event sends NO white ATTACKERSTATEUPDATE. killing_blow/ATTACKSTOP below still applies. - let m = codec::build_attacker_state_update( - row.attacker_guid, - row.target_guid, - row.damage, - row.hit_info, - row.blocked_amount, - 0, - ); - out.push(Outbound::One( - ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(m)), - )); - } - // C2: on a MELEE killing blow, tell the attacker to leave combat stance. The target itself - // vanishes via the game_world_entity on_delete → SMSG_DESTROY_OBJECT relay. A RANGED kill - // sends no ATTACKSTOP (the client was never in melee-attack state; vanilla sends none) — - // its stop signal is the SMSG_CANCEL_AUTO_REPEAT from the engagement-row delete relay - // (kill_creature's disengage frees the attacker's row). - if row.killing_blow && row.ranged_spell_id == 0 { - let stop = codec::build_attack_stop(row.attacker_guid, row.target_guid); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( - Box::new(stop), - ))); - } - out -} - -/// Combat-stance engage leg: SMSG_ATTACKSTART for a melee engagement — -/// the one body both legs run. Ranged rows are NOT melee combat (Auto Shot: ATTACKSTART would animate a -/// melee swing between shots); the `created` gate skips an out-of-scope attacker like the swing -/// relay. -pub(crate) fn melee_engage_outbound( - created: &Mutex>, - row: &MeleeAttack, -) -> Vec { - if row.ranged_spell_id != 0 { - return Vec::new(); - } - if !created.lock().unwrap().contains(&row.attacker_guid) { - return Vec::new(); - } - let m = codec::build_attack_start(row.attacker_guid, row.target_guid); - vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( - Box::new(m), - ))] -} - -/// Combat-stance disengage leg. A RANGED row's delete is the ONE -/// server-initiated auto-repeat teardown choke point: the OWNING player (and only them) gets the -/// 0-byte SMSG_CANCEL_AUTO_REPEAT so its toggle drops in lockstep (Auto Shot); a melee row's delete is -/// SMSG_ATTACKSTOP for everyone (a non-kill disengage leaves stance too). -pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec { - if row.ranged_spell_id != 0 { - if row.attacker_guid == self_guid { - return vec![Outbound::One(ServerOpcodeMessage::SMSG_CANCEL_AUTO_REPEAT)]; - } - return Vec::new(); - } - let m = codec::build_attack_stop(row.attacker_guid, row.target_guid); - vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( - Box::new(m), - ))] -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SpellCastEventKind { - Start, - Go, - Interrupt, - Pushback, - ProcLog, -} - -impl SpellCastEventKind { - /// Decode the append-only Module code. Kind zero is a row from the old schema and keeps the - /// former field-based order. An unknown nonzero kind is newer or damaged data, so guessing a - /// client packet would be unsafe. - fn decode(row: &SpellCastEvent) -> Option { - match row.kind { - 0 if row.is_interrupted => Some(Self::Interrupt), - 0 if row.delay_ms > 0 => Some(Self::Pushback), - 0 if row.is_proc_log => Some(Self::ProcLog), - 0 if row.cast_time_ms > 0 => Some(Self::Start), - 0 => Some(Self::Go), - 1 => Some(Self::Start), - 2 => Some(Self::Go), - 3 => Some(Self::Interrupt), - 4 => Some(Self::Pushback), - 5 => Some(Self::ProcLog), - unknown => { - log::warn!( - "spell cast relay: unknown kind {unknown} (event {}, caster {}, spell {})", - row.id, - row.caster_guid, - row.spell_id - ); - None - } - } - } -} - -/// Spell cast visuals: the one body both paths run. It handles START and GO sequencing, interrupt -/// teardown, pushback, Proc logs, damage and heal logs, and cooldown. Every caster-private branch -/// keys on `self_guid`. -pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec { - let mut out = Vec::new(); - let Some(kind) = SpellCastEventKind::decode(row) else { - return out; - }; - // INTERRUPT signal: the victim's mid-cast timed spell was cancelled. - // Relay SMSG_SPELL_FAILURE{spell, Interrupted} to the caster so the client tears down its cast - // bar. No SMSG_SPELL_GO follows because the cast never resolved. - if kind == SpellCastEventKind::Interrupt { - // SELF-ONLY for a PLAYER caster: game_spell_cast_event is a global public subscription, - // so this closure fires for EVERY player. SMSG_SPELL_FAILURE is a caster-private cast-bar - // teardown (unlike the START/GO broadcast visuals), so relay it ONLY to the caster — else - // bystanders get stray "Interrupted" feedback and a bystander mid-casting the same spell - // risks a spurious teardown. - // A creature or pet caster (0xF130/0xF140 high-guid) has no self — broadcast so every observer's - // mob cast bar tears down on a Kick/Counterspell (the packet carries the mob's guid, so - // a bystander's own bar is untouched; mirrors the START broadcast that drew the bar). - let is_creature = matches!(row.caster_guid >> 48, 0xF130 | 0xF140); - if is_creature || row.caster_guid == self_guid { - let m = codec::build_spell_failure(row.caster_guid, row.spell_id); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE( - Box::new(m), - ))); - } - // A row that names a concrete reason (an on-next-swing strike that could not pay its cost at - // the swing) follows the teardown with the failed cast result, in vanilla's order - // (SendInterrupted then SendCastResult). The client needs BOTH: the teardown closes the cast - // bar, the cast result releases the ability it holds as its current melee spell and prints - // the red "Not enough rage" line. Caster-private, like the teardown above — the raw encoder, - // because the typed SMSG_CAST_RESULT inverts the success/failure semantics. - if row.caster_guid == self_guid { - if let Some(reason) = codec::cast_failure_reason_for_code(row.failure_reason) { - out.push(Outbound::Raw { - opcode: 0x0130, - body: codec::build_cast_result_failed(row.spell_id, reason), - }); - } - } - return out; - } - // PUSHBACK signal: a direct hit moved the fire time of an in-progress timed cast. Broadcast - // SMSG_SPELL_DELAYED so anyone watching the caster's cast bar sees the same shift. - if kind == SpellCastEventKind::Pushback { - let m = codec::build_spell_delayed(row.caster_guid, row.delay_ms); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED( - Box::new(m), - ))); - return out; - } - // Proc and ranged-impact rows report damage without another cast animation or cooldown. - if kind == SpellCastEventKind::ProcLog { - if row.damage > 0 { - let log = codec::build_spell_non_melee_damage_log( - row.target_guid, - row.caster_guid, - row.spell_id, - row.damage, - row.school, - row.is_crit, - row.resisted, - row.absorbed, - ); - out.push(Outbound::One( - ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), - )); - } - return out; - } - if kind == SpellCastEventKind::Start { - // Cast-START (a timed spell): SMSG_SPELL_START with the cast-bar duration so observers - // see the bar FILL. The GO/COOLDOWN follow on the cast-GO COMPLETION event. - let start = - codec::build_spell_start(row.caster_guid, row.spell_id, row.cast_time_ms, 0, None); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( - Box::new(start), - ))); - return out; - } - // Cast-GO. Legacy rows reach this branch when cast_time_ms is zero. Sequence: - // - GENUINE INSTANT (is_completion=false): START(0)+GO — SendSpellStart fires for EVERY - // non-triggered cast (timer 0 for an instant) to register the pending cast, then cast() → - // SendSpellGo finalizes it. START flags = 0x02, GO flags = 0x0100 (set in the builders). - // - TIMED COMPLETION (is_completion=true): GO ALONE — the begin already sent START(cast_time); - // a 2nd START(0) reset the bar to zero-length ("stuck on full"). The SMSG_SPELL_GO is the - // client's cast finalizer (it matches by caster guid + spell id to release the pending cast). - // The CASTER's OWN instant cast (cast_time 0 → this GO branch, !is_completion) already got - // START+GO SYNCHRONOUSLY from the CMSG_CAST_SPELL handler (so they precede the aura effects the - // SDK's alphabetical callback order would otherwise send first). Skip the duplicate to the caster - // — but still relay it to OBSERVERS (caster != self) and for TIMED COMPLETIONS (is_completion, - // which the gateway did NOT sync-send). NOTE: a player's TRIGGERED instant cast (channel-tick - // missile / on-hit trigger) also matches !is_completion && caster==self and is suppressed here — - // it did not get a synchronous send, so it loses its caster-side visual until the client_initiated - // flag lands (tracked follow-up). Acceptable for the slice; observers still see it. - // Suppress ONLY what the CMSG handler actually sent synchronously — the row says so - // (client_initiated rides from the cast_spell reducer path alone). The old shape - // (!is_completion && self) also swallowed the caster's TRIGGERED instants — channel-tick - // missiles (Arcane Missiles/Drain Life), on-hit trigger_spell procs — which never had a - // synchronous send, so their caster saw no visual while observers did. - let caster_synced_instant = row.client_initiated && row.caster_guid == self_guid; - if !caster_synced_instant { - if !row.is_completion { - let start = codec::build_spell_start(row.caster_guid, row.spell_id, 0, 0, None); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( - Box::new(start), - ))); - } - // CAST_RESULT(OK) is caster-only: clears the pending spell state so the subsequent - // GO can release m_currentSpells. Only for timed completions — the instant-cast - // caster got it synchronously from the CMSG_CAST_SPELL handler. - if row.is_completion && row.caster_guid == self_guid { - out.push(Outbound::Raw { - opcode: 0x0130, - body: codec::build_cast_result_ok(row.spell_id), - }); - } - let mut go = codec::build_spell_go(row.caster_guid, row.spell_id, row.target_guid, None); - // A 0-damage on-next-swing FIRE that rode a missed/dodged/parried swing reports - // the outcome in the GO's miss list — the client prints the yellow "Your Heroic - // Strike missed/was dodged/was parried" line (the white MISS was suppressed via - // spell_swing). swing_hit_info uses the CombatEvent codes (2 miss, 3 dodge, 4 parry); - // every non-swing row carries 0, keeping this branch dead for ordinary casts. - if row.is_completion && row.damage == 0 { - use wow_world_messages::vanilla::{SpellMiss, SpellMissInfo}; - let miss_info = match row.swing_hit_info { - 2 => Some(SpellMissInfo::Miss), - 3 => Some(SpellMissInfo::Dodge), - 4 => Some(SpellMissInfo::Parry), - _ => None, - }; - if let Some(mi) = miss_info { - go.hits.clear(); - go.misses = vec![SpellMiss { - target: wow_world_messages::Guid::new(row.target_guid), - miss_info: mi, - }]; - } - } - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(Box::new( - go, - )))); - } - - if row.damage > 0 { - let log = codec::build_spell_non_melee_damage_log( - row.target_guid, - row.caster_guid, - row.spell_id, - row.damage, - row.school, - row.is_crit, - row.resisted, - row.absorbed, - ); - out.push(Outbound::One( - ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), - )); - } - // The green floating heal number + combat-log line — SMSG_SPELLHEALLOG whenever - // this cast restored health (module sums EFFECTIVE heal onto the row; overheal-only - // casts carry 0 and stay silent, matching the damage gate's shape). - if row.healed > 0 { - use wow_world_messages::vanilla::SMSG_SPELLHEALLOG; - use wow_world_messages::Guid; - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELLHEALLOG( - Box::new(SMSG_SPELLHEALLOG { - victim: Guid::new(row.target_guid), - caster: Guid::new(row.caster_guid), - id: row.spell_id, - damage: row.healed, - critical: row.is_crit, - }), - ))); - } - - // SMSG_SPELL_GO releases pending casts. A zero-duration cooldown can leave the button stuck. - if row.cooldown_ms > 0 { - let cd = codec::build_spell_cooldown(row.caster_guid, row.spell_id, row.cooldown_ms); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_COOLDOWN( - Box::new(cd), - ))); - } - out -} - -/// The full aura-array VALUES block for `target_guid`. `auras` is that unit's current aura set -/// ([`crate::stdb::world_view::AuraIndex::on_target`]); the filter only guards a caller that -/// hands over a wider set. -pub(crate) fn aura_sync( - auras: impl Iterator, - target_guid: u64, -) -> Outbound { - let slots: Vec = auras - .filter(|a| a.target_guid == target_guid) - .map(|a| codec::update_mask::AuraSlot { - slot: a.slot, - spell_id: a.spell_id, - flags: a.flags, - level: a.level, - }) - .collect(); - let mask = codec::update_mask::full_aura_mask(&slots); - let (opcode, body) = codec::build_values_update_raw(target_guid, &mask); - Outbound::Raw { opcode, body } -} -// A self resistance aura applies or expires after the entity CREATE. Recompute all seven effective -// resistances from the coordinator's post-change cache and push the complete paperdoll group. -pub(crate) fn armor_packet( - coord: &Coordinator, - changed: &Aura, - self_guid: u64, -) -> Option { - const A_MOD_RESISTANCE: u8 = 0xA1; // taxonomy A_MOD_RESISTANCE - const RESISTANCE_MASK: u32 = 0x7f; - if changed.target_guid != self_guid - || changed.eff_kind != A_MOD_RESISTANCE - || (changed.eff_p0 as u32 & RESISTANCE_MASK) == 0 - { - return None; - } - // The coordinator owns the entity, aura, item-instance and item-catalogue caches needed by this fold. - let guard = coord.0.coord(); - let db = &guard.conn.db; - let eff = super::armor::effective_resistances(db, self_guid); - // Carry the positive AURA portion alongside the total so the paperdoll renders the - // green "(+N)" (Devotion Aura showed as plain white armor). Raw path — the positive - // field has no gtker setter. Login self-corrects through this same relay (the SDK - // replays aura rows after subscription-apply). - let pos = super::armor::aura_armor_positive(db, self_guid); - Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_armor_values(self_guid, eff, pos)), - ))) -} -// The Module has already persisted sheet stats in the aura transaction. Re-push that row -// only for aura kinds that affect it; the paperdoll is private to its owner. -pub(crate) fn sheet_packet( - coord: &Coordinator, - changed: &Aura, - self_guid: u64, -) -> Option { - const A_MOD_STAT: u8 = 0xA0; // taxonomy A_MOD_STAT - const A_MOD_COMBAT: u8 = 0xA3; // taxonomy A_MOD_COMBAT - const COMBAT_ATTACK_POWER: i32 = 0; // taxonomy COMBAT_ATTACK_POWER - let moves_sheet = changed.eff_kind == A_MOD_STAT - || (changed.eff_kind == A_MOD_COMBAT && changed.eff_p0 == COMBAT_ATTACK_POWER); - if changed.target_guid != self_guid || !moves_sheet { - return None; - } - let guard = coord.0.coord(); - let db = &guard.conn.db; - let st = super::armor::sheet_stats(db, self_guid); - drop(guard); - st.map(|st| { - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_sheet_stats_values(self_guid, &st), - ))) - }) -} -/// Hide a peer on stealth gain; reveal it with current equipment and auras on stealth loss. -/// The AOI and `created` checks prevent distant or duplicate CREATEs. Self never hides from itself. -#[allow(clippy::too_many_arguments)] -pub(crate) fn stealth_visibility( - post_change_stealth_count: usize, - view: &WorldView, - shard: super::world_index::ShardId, - session: u64, - coord: &Coordinator, - created: &Arc>>, - changed: &Aura, - self_guid: u64, - is_insert: bool, -) -> Vec { - if changed.eff_kind != A_STEALTH || changed.target_guid == self_guid { - return Vec::new(); - } - match stealth_action(is_insert, post_change_stealth_count) { - StealthAction::Hide => { - // Evict so a later REVEAL (and ordinary AOI re-entry) re-CREATEs; only DESTROY if the - // viewer actually had it (idempotent — a never-created peer needs no DESTROY). - if created.lock().unwrap().remove(&changed.target_guid) { - vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(changed.target_guid), - ))] - } else { - Vec::new() - } - } - StealthAction::Reveal => { - // Re-CREATE only if not currently shown (idempotent) AND the peer is in this viewer's - // scope (its row is in the cache). Insert into `created` BEFORE building so the dedup - // is consistent; if the row is gone (out of scope), undo the insert and emit nothing. - if !created.lock().unwrap().insert(changed.target_guid) { - return Vec::new(); - } - - if !view - .spatial - .can_see(EntityLayer::WorldEntity, session, changed.target_guid) - { - created.lock().unwrap().remove(&changed.target_guid); - return Vec::new(); - } - match coord - .0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&changed.target_guid) - { - Some(row) => match build_peer_create(coord, view, shard, self_guid, &row) { - Some(out) => out, - None => { - // Encode failure: roll the dedup entry back like `offer_peer_create` - // does, else this guid is permanently suppressed (marked created with - // no CREATE ever sent) — the same latent re-entry bug fixed on the - // insert/update path. - created.lock().unwrap().remove(&changed.target_guid); - Vec::new() - } - }, - None => { - created.lock().unwrap().remove(&changed.target_guid); - Vec::new() - } - } - } - StealthAction::None => Vec::new(), - } -} - -/// Channel timers belong to the caster, including channels whose auras sit on an enemy. -fn spell_channel_outbound( - coord: &Coordinator, - self_guid: u64, - row: &Aura, - removed: bool, -) -> Option { - use wow_world_messages::vanilla::{MSG_CHANNEL_START_Server, MSG_CHANNEL_UPDATE_Server}; - if row.caster_guid != self_guid { - return None; - } - let guard = coord.0.coord(); - let db = &guard.conn.db; - let is_channel = |aura: &Aura| { - let flags = db - .game_spell() - .spell_id() - .find(&aura.spell_id) - .map_or(0, |spell| spell.cast_flags); - lyracore_shared::spell::is_channel_aura(aura.eff_kind, flags) - }; - if !is_channel(row) { - return None; - } - let active = db - .game_aura() - .iter() - .filter(|aura| aura.caster_guid == self_guid && is_channel(aura)) - .min_by_key(|aura| aura.id); - let packet = if removed { - if active.is_some() { - return None; - } - ServerOpcodeMessage::MSG_CHANNEL_UPDATE(MSG_CHANNEL_UPDATE_Server { time: 0 }) - } else { - if active.as_ref().is_none_or(|active| active.id != row.id) { - return None; - } - let duration = ((row.expires_at.to_micros_since_unix_epoch() - - row.applied_at.to_micros_since_unix_epoch()) - / 1000) - .clamp(0, u32::MAX as i64) as u32; - ServerOpcodeMessage::MSG_CHANNEL_START(MSG_CHANNEL_START_Server { - spell: row.spell_id, - duration, - }) - }; - Some(Outbound::One(packet)) -} - -/// Aura insert leg: array sync, self-only duration/run-speed/armor/sheet packets, and the -/// stealth HIDE transition. The target's current aura set comes from the gateway's aura index, -/// post-change like the cache it mirrors; `stealth_count` was taken on the pump. -#[allow(clippy::too_many_arguments)] -pub(crate) fn aura_insert_outbound( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - session: u64, - created: &Arc>>, - self_guid: u64, - row: &Aura, - stealth_count: usize, -) -> Vec { - let mut out = Vec::new(); - out.extend(spell_channel_outbound(coord, self_guid, row, false)); - // A stealth-hidden peer (not in `created`) must get NO per-peer relay: a partial VALUES on - // a DESTROYed object is a client crash/desync vector. - let visible = - row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); - let current = visible.then(|| view.auras.on_target(shard, row.target_guid)); - if let Some(current) = ¤t { - out.push(aura_sync(current.iter().cloned(), row.target_guid)); - } - if let Some(o) = aura_duration_packet(row, self_guid) { - out.push(o); - } - if let Some(current) = current { - if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { - out.push(o); - } - if let Some(o) = armor_packet(coord, row, self_guid) { - out.push(o); - } - if let Some(o) = sheet_packet(coord, row, self_guid) { - out.push(o); - } - } - out.extend(stealth_visibility( - stealth_count, - view, - shard, - session, - coord, - created, - row, - self_guid, - true, - )); - out -} - -/// Aura update leg (no stealth transition on an update). -pub(crate) fn aura_update_outbound( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - created: &Arc>>, - self_guid: u64, - row: &Aura, - expires_changed: bool, -) -> Vec { - let mut out = Vec::new(); - let visible = - row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); - let current = visible.then(|| view.auras.on_target(shard, row.target_guid)); - if let Some(current) = ¤t { - out.push(aura_sync(current.iter().cloned(), row.target_guid)); - } - // Re-send the timer only when the duration window actually changed (a refresh). - if expires_changed { - if let Some(o) = aura_duration_packet(row, self_guid) { - out.push(o); - } - } - if let Some(current) = current { - if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { - out.push(o); - } - if let Some(o) = armor_packet(coord, row, self_guid) { - out.push(o); - } - if let Some(o) = sheet_packet(coord, row, self_guid) { - out.push(o); - } - } - out -} - -/// Aura delete leg (the REVEAL half of stealth). The aura index is post-delete, so the -/// sync/speed folds read the remaining set. -#[allow(clippy::too_many_arguments)] -pub(crate) fn aura_delete_outbound( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - session: u64, - created: &Arc>>, - self_guid: u64, - row: &Aura, - stealth_count: usize, -) -> Vec { - let mut out = Vec::new(); - out.extend(spell_channel_outbound(coord, self_guid, row, true)); - let visible = - row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); - if visible { - let current = view.auras.on_target(shard, row.target_guid); - out.push(aura_sync(current.iter().cloned(), row.target_guid)); - if let Some(o) = run_speed_packet(current.into_iter(), row, self_guid) { - out.push(o); - } - if let Some(o) = armor_packet(coord, row, self_guid) { - out.push(o); - } - if let Some(o) = sheet_packet(coord, row, self_guid) { - out.push(o); - } - } - out.extend(stealth_visibility( - stealth_count, - view, - shard, - session, - coord, - created, - row, - self_guid, - false, - )); - out -} - -/// Reconnect sends one current aura array per target and observer. Owner timers remain per aura, -/// while speed, armor, and sheet values refresh once even when several auras changed offline. -pub(crate) fn aura_snapshot_outbound( - coord: &Coordinator, - view: &WorldView, - shard: super::world_index::ShardId, - viewer: &Viewer, - target_guid: u64, - previous: &[Aura], -) -> Vec { - let current = view.auras.on_target(shard, target_guid); - let stealth_count = current - .iter() - .filter(|row| row.eff_kind == A_STEALTH) - .count(); - let mut out = Vec::new(); - if let Some(stealth) = current - .iter() - .chain(previous) - .find(|row| row.eff_kind == A_STEALTH) - { - out.extend(stealth_visibility( - stealth_count, - view, - shard, - viewer.session, - coord, - &viewer.created, - stealth, - viewer.self_guid, - stealth_count > 0, - )); - // Reveal includes the aura array; hide must not send VALUES after DESTROY. - if !out.is_empty() { - return out; - } - } - if target_guid != viewer.self_guid && !viewer.created.lock().unwrap().contains(&target_guid) { - return out; - } - out.push(aura_sync(current.iter().cloned(), target_guid)); - if target_guid != viewer.self_guid { - return out; - } - out.extend( - current - .iter() - .filter_map(|row| aura_duration_packet(row, viewer.self_guid)), - ); - out.extend( - current - .iter() - .chain(previous) - .find_map(|row| run_speed_packet(current.iter().cloned(), row, viewer.self_guid)), - ); - out.extend( - current - .iter() - .chain(previous) - .find_map(|row| armor_packet(coord, row, viewer.self_guid)), - ); - out.extend( - current - .iter() - .chain(previous) - .find_map(|row| sheet_packet(coord, row, viewer.self_guid)), - ); - out -} - -/// Trade-status relay: `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the -/// row's recipient and nobody else (audience resolved by the caller, the `auction_notice_outbound` -/// shape). The kind byte is `lyracore_shared::trade::event_kind` — NOT the vanilla discriminant; -/// this match IS the wire mapping. An unknown kind (a newer module mid-rollout) drops with a warn -/// rather than desyncing the window. -pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { - use lyracore_shared::trade::event_kind as kind; - use wow_world_messages::vanilla::SMSG_TRADE_STATUS; - // The OFFER_* kinds carry a whole-side snapshot and decode to the fixed-444-byte extended - // status instead of a plain status ; `self_player` is the kind, not an inference. - if row.kind == kind::OFFER_SELF || row.kind == kind::OFFER_PARTNER { - // WIRE POLARITY (mangoszero `SendUpdateTrade`): the byte is `1 means traders data, - // 0 means own` — so the field is SET when the packet describes the PARTNER's side, - // despite the binding's `self_player` name. The protocol's polarity controls this value. - return match trade_offer_extended(row.kind == kind::OFFER_PARTNER, &row.payload) { - Some(msg) => vec![Outbound::One( - ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(Box::new(msg)), - )], - None => { - log::warn!( - "trade relay: unparseable offer payload {:?} (event {})", - row.payload, - row.id - ); - Vec::new() - } - }; - } - let status = match row.kind { - kind::BEGIN_TRADE => Some(SMSG_TRADE_STATUS::BeginTrade { - unknown1: wow_world_messages::Guid::new(row.other_guid), - }), - kind::OPEN_WINDOW => Some(SMSG_TRADE_STATUS::OpenWindow), - kind::TRADE_CANCELED => Some(SMSG_TRADE_STATUS::TradeCanceled), - kind::BUSY => Some(SMSG_TRADE_STATUS::Busy), - kind::NO_TARGET => Some(SMSG_TRADE_STATUS::NoTarget), - kind::TARGET_TO_FAR => Some(SMSG_TRADE_STATUS::TargetToFar), - kind::WRONG_FACTION => Some(SMSG_TRADE_STATUS::WrongFaction), - kind::YOU_DEAD => Some(SMSG_TRADE_STATUS::YouDead), - kind::TARGET_DEAD => Some(SMSG_TRADE_STATUS::TargetDead), - kind::IGNORE_YOU => Some(SMSG_TRADE_STATUS::IgnoreYou), - kind::TRADE_ACCEPT => Some(SMSG_TRADE_STATUS::TradeAccept), - kind::BACK_TO_TRADE => Some(SMSG_TRADE_STATUS::BackToTrade), - kind::TRADE_COMPLETE => Some(SMSG_TRADE_STATUS::TradeComplete), - // Commit refused on bag space: the window closes with the inventory error; - // `target_error` says WHOSE bags — false = yours, true = the partner's. - kind::INV_FULL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { - inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, - item_limit_category_id: 0, - target_error: false, - }), - kind::INV_FULL_PARTNER => Some(SMSG_TRADE_STATUS::CloseWindow { - inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, - item_limit_category_id: 0, - target_error: true, - }), - kind::GOLD_FAIL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { - inventory_result: wow_world_messages::vanilla::InventoryResult::NotEnoughMoney, - item_limit_category_id: 0, - target_error: false, - }), - kind::GOLD_FAIL_PARTNER => Some(SMSG_TRADE_STATUS::CloseWindow { - inventory_result: wow_world_messages::vanilla::InventoryResult::NotEnoughMoney, - item_limit_category_id: 0, - target_error: true, - }), - other => { - log::warn!("trade relay: unknown kind {other} (event {})", row.id); - None - } - }; - match status { - Some(s) => vec![Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS( - Box::new(s), - ))], - None => Vec::new(), - } -} - -/// Project one Duel lifecycle edge for one recipient. The module inserts one row per participant, -/// so this function deliberately builds one copy of each typed protocol packet per row. -pub(crate) fn duel_event_outbound( - row: &DuelEvent, - template: Option<&codec::GameObjectTemplateView>, -) -> Vec { - use lyracore_shared::duel::{completion_kind, event_kind}; - use wow_world_messages::vanilla::{ - SMSG_DUEL_COMPLETE, SMSG_DUEL_COUNTDOWN, SMSG_DUEL_REQUESTED, - }; - - let raw_values = |guid, arbiter, team| { - let (opcode, body) = codec::build_duel_player_values(guid, arbiter, team); - Outbound::Raw { opcode, body } - }; - - match row.kind { - event_kind::REQUESTED => { - let Some(template) = template else { - log::warn!( - "duel relay: missing duel-flag template {} (event {})", - row.flag_entry, - row.id - ); - return Vec::new(); - }; - let flag = codec::GameObjectView { - guid: row.flag_guid, - template_entry: row.flag_entry, - x: row.flag_x, - y: row.flag_y, - z: row.flag_z, - orientation: row.flag_orientation, - state: 1, - type_id: template.type_id, - display_id: template.display_id, - rotation_0: 0.0, - rotation_1: 0.0, - rotation_2: 0.0, - rotation_3: 0.0, - size: 1.0, - }; - let (rotation_opcode, rotation_body) = codec::build_gameobject_rotation_values(&flag); - vec![ - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_gameobject_create_object(&flag), - ))), - Outbound::Raw { - opcode: rotation_opcode, - body: rotation_body, - }, - raw_values(row.initiator_guid, Some(row.flag_guid), None), - raw_values(row.challenged_guid, Some(row.flag_guid), None), - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_REQUESTED(Box::new( - SMSG_DUEL_REQUESTED { - // The protocol calls this `initiator`; vanilla uses it as the arbiter GO. - initiator: wow_world_messages::Guid::new(row.flag_guid), - target: wow_world_messages::Guid::new(row.initiator_guid), - }, - ))), - ] - } - event_kind::COUNTDOWN => vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COUNTDOWN( - SMSG_DUEL_COUNTDOWN { - // The library labels this Duration as seconds, but writes `as_secs()` directly. - // Vanilla's field is milliseconds and CMaNGOS writes 3000, so 3000 seconds here - // is the typed API spelling that produces the correct u32 wire value. - time: Duration::from_secs(3_000), - }, - ))], - event_kind::ACTIVE => vec![ - raw_values(row.initiator_guid, None, Some(1)), - raw_values(row.challenged_guid, None, Some(2)), - ], - event_kind::OUT_OF_BOUNDS => { - vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_OUTOFBOUNDS)] - } - event_kind::IN_BOUNDS => vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_INBOUNDS)], - event_kind::COMPLETE => { - let mut outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE( - SMSG_DUEL_COMPLETE { - ended_without_interruption: row.completion_kind != completion_kind::INTERRUPTED, - }, - ))]; - outbound.extend(duel_winner_outbound(row)); - outbound.extend([ - raw_values(row.initiator_guid, Some(0), Some(0)), - raw_values(row.challenged_guid, Some(0), Some(0)), - Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(row.flag_guid), - )), - ]); - outbound - } - other => { - log::warn!("duel relay: unknown kind {other} (event {})", row.id); - Vec::new() - } - } -} - -/// The one winner announcement for a completed Duel. Participant relays include it after -/// `SMSG_DUEL_COMPLETE`; the shared view also fans it to nearby non-participants. -pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { - use lyracore_shared::duel::completion_kind; - use wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason; - use wow_world_messages::vanilla::SMSG_DUEL_WINNER; - - if row.winner_guid == 0 - || row.loser_guid == 0 - || row.winner_name.is_empty() - || row.loser_name.is_empty() - { - return Vec::new(); - } - let reason = match row.completion_kind { - completion_kind::WON => DuelWinnerReason::Won, - completion_kind::FLED => DuelWinnerReason::Fled, - _ => return Vec::new(), - }; - // `opponent_name` is the first CString on the wire and carries the WINNER: mangos sends - // this packet from the loser, announcing its opponent as victor. - vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER( - Box::new(SMSG_DUEL_WINNER { - reason, - opponent_name: row.winner_name.clone(), - initiator_name: row.loser_name.clone(), - }), - ))] -} - -/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED`: -/// counts are 7/7 (the cmangos constant), unused slots stay zeroed (`TradeSlot::default`), and -/// every filled slot carries the module-resolved stack/durability/enchant fields. Fails closed -/// with the payload decoder. `describes_partner` sets the wire's misnamed `self_player` byte — -/// mangoszero: "1 means traders data, 0 means own". -fn trade_offer_extended( - describes_partner: bool, - payload: &str, -) -> Option { - use wow_world_messages::vanilla::{TradeSlot, SMSG_TRADE_STATUS_EXTENDED}; - let (gold, views) = lyracore_shared::trade::decode_offer(payload)?; - let mut trade_slots = [TradeSlot::default(); 7]; - for v in views { - let slot = trade_slots.get_mut(v.trade_slot as usize)?; - *slot = TradeSlot { - trade_slot_number: v.trade_slot, - item: v.entry, - display_id: v.display_id, - stack_count: v.stack_count, - enchantment: client_enchantment_id(v.enchantment), - item_random_properties_id: v.random_property_id, - max_durability: v.max_durability, - durability: v.durability, - ..TradeSlot::default() - }; - } - Some(SMSG_TRADE_STATUS_EXTENDED { - // `1 means traders data` — see the call-site polarity note. - self_player: describes_partner, - trade_slot_count1: 7, - trade_slot_count2: 7, - money_in_trade: wow_world_messages::vanilla::Gold::new(gold), - spell_on_lowest_slot: 0, - trade_slots, - }) -} - -/// Group / loot-roll / quest-share: the ONE kind-decode body both legs -/// run. PRIVATE data — the audience (the row's recipient, and nobody else) is resolved by the -/// caller (RLS on the per-player leg; the owner-session lookup + `private_recipient_audience` on -/// the shared leg). `store` is the connection the row came from: Realm-core on a sharded Realm. -/// The LIST render reads every World Shard through it, and QUEST_SHARE reads the quest detail. -#[allow(clippy::too_many_lines)] // One arm per group event kind. -pub(crate) fn group_event_outbound< - St: CharacterStore + QuestActionStore + SessionStore + ShardRoutingStore + ?Sized, ->( - store: &St, - self_guid: u64, - row: &GroupEvent, -) -> Vec { - use lyracore_shared::group::event_kind as group_kind; - use lyracore_shared::loot_roll::event_kind as roll_kind; - use lyracore_shared::meeting_stone::event_kind as stone_kind; - use lyracore_shared::quest::share_event_kind as quest_share_kind; - let msg = match row.kind { - // Sent even when no shard can name the inviter (falls back to an empty name, like - // `render_list` does for an unresolved roster member): the popup is the target's only way - // to learn of the invite, and the pending `game_group_invite` row already committed. - // Skipping the packet would strand it for the whole 2-minute invite GC. - group_kind::INVITE => Some(ServerOpcodeMessage::SMSG_GROUP_INVITE(Box::new( - codec::build_group_invite( - other_character_name(store, row, "INVITE").unwrap_or_default(), - ), - ))), - // The same renderer world entry uses: presence and blank names come from the shard caches, - // because a roster written on realm-core can know neither. A Party's Target Icons follow - // in the same job, because the Party client clears its marks on the list. - group_kind::LIST => match lyracore_shared::group::RosterPayload::decode(&row.payload) { - Some(roster) => { - let mut packets = vec![crate::world::party::render_list(store, self_guid, &roster)]; - if !roster.target_icons.is_empty() { - let (opcode, body) = codec::build_target_icon_list_raw(&roster.target_icons); - packets.push(Outbound::Raw { opcode, body }); - } - return packets; - } - None => { - log::warn!( - "group LIST relay: unparseable roster payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - // Same fallback as INVITE, for the same reason: consistent behavior for the two kinds - // that answer an invite dialog. - group_kind::DECLINE => Some(ServerOpcodeMessage::SMSG_GROUP_DECLINE(Box::new( - codec::build_group_decline( - other_character_name(store, row, "DECLINE").unwrap_or_default(), - ), - ))), - group_kind::DESTROYED => Some(ServerOpcodeMessage::SMSG_GROUP_DESTROYED), - 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, - ))) - }), - roll_kind::ROLL_START => match lyracore_shared::loot_roll::decode_start(&row.payload) { - Some((corpse_guid, slot, item_entry, countdown_ms, random_property_id)) => Some( - ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new(codec::build_loot_start_roll( - corpse_guid, - slot, - item_entry, - countdown_ms, - random_property_id, - ))), - ), - None => { - log::warn!( - "loot ROLL_START relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - roll_kind::ROLL_VOTE => match lyracore_shared::loot_roll::decode_vote(&row.payload) { - Some((corpse, slot, entry, number, vote, _auto_pass, property_id)) => Some( - ServerOpcodeMessage::SMSG_LOOT_ROLL(Box::new(codec::build_loot_roll( - corpse, - slot, - row.other_guid, - entry, - number, - vote, - property_id, - ))), - ), - None => { - log::warn!( - "loot ROLL_VOTE relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - roll_kind::ROLL_WON => match lyracore_shared::loot_roll::decode_won(&row.payload) { - Some((corpse, slot, entry, number, vote, property_id)) => Some( - ServerOpcodeMessage::SMSG_LOOT_ROLL_WON(Box::new(codec::build_loot_roll_won( - corpse, - slot, - entry, - row.other_guid, - number, - vote, - property_id, - ))), - ), - None => { - log::warn!( - "loot ROLL_WON relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - roll_kind::MASTER_LIST => { - match lyracore_shared::loot_roll::decode_master_list(&row.payload) { - Some((_corpse_guid, eligible)) => Some(ServerOpcodeMessage::SMSG_LOOT_MASTER_LIST( - Box::new(codec::build_loot_master_list(&eligible)), - )), - None => { - log::warn!( - "loot MASTER_LIST relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - } - } - - roll_kind::MONEY_SHARE => { - match lyracore_shared::loot_roll::decode_money_share(&row.payload) { - Some(share) => Some(ServerOpcodeMessage::SMSG_LOOT_MONEY_NOTIFY( - codec::build_loot_money_notify(share), - )), - None => { - log::warn!( - "loot MONEY_SHARE relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - } - } - // Quest sharing: an eligible party member receives the shared quest — - // `row.other_guid` is the SHARER (resolved/pushed by `group::push_event`), `row.payload` - // is the quest entry. Opens the DETAILS screen with the SHARER as "giver" (the - // recipient's own `CMSG_QUESTGIVER_ACCEPT_QUEST` then re-validates fresh via the - // module's `GiverKind::Party` — this relay never authorizes anything by itself). - quest_share_kind::QUEST_SHARE => match row.payload.parse::() { - Ok(quest_id) => match store.quest_detail_view(quest_id) { - Ok(Some(detail)) => { - let (opcode, body) = codec::build_quest_details_raw(row.other_guid, &detail); - return vec![Outbound::Raw { opcode, body }]; - } - Ok(None) => { - log::warn!( - "quest QUEST_SHARE relay: quest {quest_id} not loaded (event {})", - row.id - ); - None - } - Err(e) => { - log::warn!( - "quest QUEST_SHARE relay: quest_detail lookup failed (event {}): {e}", - row.id - ); - None - } - }, - Err(_) => { - log::warn!( - "quest QUEST_SHARE relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - // Quest sharing: the SENDER's per-member feedback line, one row per member, - // always (whether or not the share actually landed) — `row.other_guid` is that member, - // `row.payload` is the `share_result` wire byte (mirrors gtker's `QuestPartyMessage` 1:1). - quest_share_kind::QUEST_PUSH_RESULT => match row.payload.parse::() { - Ok(code) => Some(ServerOpcodeMessage::MSG_QUEST_PUSH_RESULT(Box::new( - codec::build_quest_push_result(row.other_guid, code), - ))), - Err(_) => { - log::warn!( - "quest QUEST_PUSH_RESULT relay: unparseable payload {:?} (event {})", - row.payload, - row.id - ); - None - } - }, - group_kind::READY_CHECK => Some(ServerOpcodeMessage::MSG_RAID_READY_CHECK(Box::new( - codec::build_ready_check(), - ))), - group_kind::READY_CHECK_ANSWER => broadcast_packet( - row, - lyracore_shared::group::decode_ready_check_answer(&row.payload), - |state| { - ServerOpcodeMessage::MSG_RAID_READY_CHECK(Box::new( - codec::build_ready_check_answer(row.other_guid, state), - )) - }, - ), - group_kind::TARGET_ICON_UPDATE => broadcast_packet( - row, - lyracore_shared::group::TargetIcon::decode(&row.payload) - .and_then(codec::build_target_icon_update), - |update| ServerOpcodeMessage::MSG_RAID_TARGET_UPDATE(Box::new(update)), - ), - group_kind::TARGET_ICON_LIST => { - match lyracore_shared::group::decode_target_icons(&row.payload) { - Some(icons) => { - let (opcode, body) = codec::build_target_icon_list_raw(&icons); - return vec![Outbound::Raw { opcode, body }]; - } - None => { - log_unparseable_broadcast(row); - None - } - } - } - group_kind::MINIMAP_PING => broadcast_packet( - row, - lyracore_shared::group::decode_minimap_ping(&row.payload), - |(x, y)| { - ServerOpcodeMessage::MSG_MINIMAP_PING(Box::new(codec::build_minimap_ping( - row.other_guid, - x, - y, - ))) - }, - ), - group_kind::RANDOM_ROLL => broadcast_packet( - row, - lyracore_shared::group::decode_random_roll(&row.payload), - |(min, max, result)| { - ServerOpcodeMessage::MSG_RANDOM_ROLL(Box::new(codec::build_random_roll( - row.other_guid, - min, - max, - result, - ))) - }, - ), - stone_kind::QUEUE => { - let packet = lyracore_shared::meeting_stone::decode_queue(&row.payload) - .and_then(|(area_id, status)| codec::build_meetingstone_setqueue(area_id, status)); - if packet.is_none() { - log::warn!( - "meeting stone QUEUE relay: unencodable payload {:?} (event {})", - row.payload, - row.id - ); - } - packet.map(ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE) - } - stone_kind::MEMBER_ADDED => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_MEMBER_ADDED( - codec::build_meetingstone_member_added(row.other_guid), - )), - stone_kind::IN_PROGRESS => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_IN_PROGRESS), - stone_kind::COMPLETE => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_COMPLETE), - other => { - log::warn!("group event relay: unknown kind {other} (id {})", row.id); - None - } - }; - match msg { - Some(m) => vec![Outbound::One(m)], - None => Vec::new(), - } -} - -/// The Character an INVITE, DECLINE or SET_LEADER row names by `other_guid`: the inviter, the -/// decliner, or the new leader. The Gateway always looks it up from the World Shard caches, -/// because Realm-core holds no `game_character` rows to resolve one from. `None` means no -/// connected shard can name it, or the lookup itself failed. -/// -/// INVITE and DECLINE fall back to an empty name and send the packet anyway, the way -/// [`crate::world::party::render_list`] fills an unresolved roster member's name with -/// `.unwrap_or_default()`: the popup is the target's only way to learn of an invite that already -/// committed durably, so dropping the packet would strand it for the whole GC window. SET_LEADER -/// skips the packet instead: the roster mirror the Gateway already pushed carries the new leader, -/// so the line is redundant rather than load-bearing. -fn other_character_name( - store: &St, - row: &GroupEvent, - relay: &str, -) -> Option { - match crate::world::presence::character_anywhere(store, row.other_guid) { - Ok(Some(character)) => Some(character.name), - Ok(None) => { - log::warn!( - "group {relay} relay: no shard names {} (event {})", - row.other_guid, - row.id - ); - None - } - Err(e) => { - log::warn!( - "group {relay} relay: name lookup for {} failed (event {}): {e:#}", - row.other_guid, - row.id - ); - None - } - } -} - -/// One Group Broadcast packet from its decoded payload. A payload that does not decode logs and -/// sends nothing, so a corrupt row never reaches the client. -fn broadcast_packet( - row: &GroupEvent, - decoded: Option, - packet: impl FnOnce(T) -> ServerOpcodeMessage, -) -> Option { - if decoded.is_none() { - log_unparseable_broadcast(row); - } - decoded.map(packet) -} - -fn log_unparseable_broadcast(row: &GroupEvent) { - log::warn!( - "group broadcast relay: unparseable kind {} payload {:?} (event {})", - row.kind, - row.payload, - row.id - ); -} - -pub(crate) fn private_recipient_audience(row_recipient_guid: u64, viewer_guid: u64) -> bool { - // 0 is "unaddressed"/"uninitialized", never a real character — an equality alone would let an - // unaddressed row match a half-initialized viewer (0 == 0), so zero denies on either side. - row_recipient_guid != 0 && row_recipient_guid == viewer_guid -} - -/// A Realm Chat Line: one `SMSG_MESSAGECHAT` for one recipient. The Relay already chose the -/// audience; an unknown Chat Kind sends nothing. -pub(crate) fn realm_chat_outbound(row: &RealmChatEvent) -> Vec { - codec::build_realm_chat_line( - row.kind, - row.speaker_guid, - row.language, - row.chat_tag, - row.channel_name.clone(), - row.message.clone(), - ) - .map(|message| Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(message)))) - .into_iter() - .collect() -} - -/// A Channel Notice: one raw `SMSG_CHANNEL_NOTIFY` for one recipient. The Relay already chose the -/// audience. -pub(crate) fn channel_notice_outbound(row: &ChatChannelNoticeEvent) -> Vec { - let (opcode, body) = codec::channel::notify(&codec::channel::ChannelNoticeView { - notice: row.notice, - channel_name: row.channel_name.clone(), - subject_guid: row.subject_guid, - actor_guid: row.actor_guid, - old_flags: row.old_flags, - new_flags: row.new_flags, - channel_flags: row.channel_flags, - text: row.text.clone(), - }); - vec![Outbound::Raw { opcode, body }] -} - -/// Auction Notice: the packet body both legs run. Audience resolved by the caller. Outbid and Won -/// go to the bidder on `SMSG_AUCTION_BIDDER_NOTIFICATION`; Sold, Expired and New bid go to the -/// owner on `SMSG_AUCTION_OWNER_NOTIFICATION`; Removed goes to the bidder a Cancellation displaced -/// on `SMSG_AUCTION_REMOVED_NOTIFICATION` (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`). -/// `house` outside the imported 1-7 range and any other `kind` both drop the notice and log — -/// the accompanying Auction Mail still reaches the recipient's inbox either way. -pub(crate) fn auction_notice_outbound(row: &AuctionNotice) -> Vec { - use lyracore_shared::auction::auction_notice::{EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON}; - - let Ok(house) = AuctionHouse::try_from(row.house) else { - log::warn!( - "auction notice: house {} is not an imported auction house; dropping kind {}", - row.house, - row.kind - ); - return Vec::new(); - }; - match row.kind { - OUTBID | WON => vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_BIDDER_NOTIFICATION(Box::new( - codec::build_auction_bidder_notification( - house, - row.auction_id, - row.bidder_guid, - row.bid, - row.out_bid, - row.item_entry, - row.random_property_id, - ), - )), - )], - SOLD | EXPIRED | NEW_BID => vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_OWNER_NOTIFICATION(Box::new( - codec::build_auction_owner_notification( - row.auction_id, - row.bid, - row.out_bid, - row.bidder_guid, - row.item_entry, - row.random_property_id, - ), - )), - )], - REMOVED => vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_REMOVED_NOTIFICATION(Box::new( - codec::build_auction_removed_notification( - row.auction_id, - row.item_entry, - row.random_property_id, - ), - )), - )], - other => { - log::warn!("auction notice: unknown kind {other}; dropping"); - Vec::new() - } - } -} - -/// Build a Package System Message after the caller validates the recipient. -pub(crate) fn system_message_event_outbound(row: &SystemMessageEvent) -> Vec { - let message = codec::build_gm_system_message(row.message.clone()); - vec![Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( - Box::new(message), - ))] -} - -/// Build a Mail Arrival after the caller validates the recipient. -pub(crate) fn mail_arrival_outbound() -> Vec { - vec![Outbound::One(ServerOpcodeMessage::SMSG_RECEIVED_MAIL( - codec::build_received_mail(), - ))] -} - -/// Resurrect prompt: the packet body both legs run. Audience: the -/// offer's target, resolved by the caller. -pub(crate) fn resurrect_request_outbound(row: &ResurrectRequest) -> Vec { - let m = codec::build_resurrect_request(row.caster_guid, row.caster_name.clone()); - vec![Outbound::One(ServerOpcodeMessage::SMSG_RESURRECT_REQUEST( - Box::new(m), - ))] -} - -/// Self-Resurrection Option: the owner's `PLAYER_SELF_RES_SPELL` write. `spell_id` 0 clears it. -/// Audience: the option's Character, resolved by the caller. -pub(crate) fn self_res_option_outbound(character_guid: u64, spell_id: u32) -> Vec { - let m = codec::build_self_res_spell_values(character_guid, spell_id); - vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(m), - ))] -} - -/// Projectile impact: the floating damage number for a projectile that -/// finished its travel — never a START/GO. Pure over the row; broadcast. -pub(crate) fn impact_event_outbound(row: &SpellImpactEvent) -> Vec { - if row.damage == 0 { - return Vec::new(); // a fully-absorbed impact (or a vanished target) logs nothing - } - let log = codec::build_spell_non_melee_damage_log( - row.target_guid, - row.caster_guid, - row.spell_id, - row.damage, - row.school, - row.is_crit, - row.resisted, - row.absorbed, - ); - vec![Outbound::One( - ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(Box::new(log)), - )] -} - -/// Nearby chat: the one body both legs run. A player speaker always hears -/// their own line; everyone else is `chat_in_range`-gated (say/`/e`/text emote ~25yd, yell ~300yd, -/// map + instance fenced), with both endpoints read from the COORDINATOR's global cache — the -/// AOI-scoped per-player cache could not see a 100–300yd YELL speaker. Missing endpoint → drop -/// (safer than flooding). `/e` (EMOTE) also gates on [`lyracore_shared::faction::same_team`] — the -/// one Chat Kind here vanilla keeps same-team-only by default; say and yell still reach both teams. -/// A player's line carries the chat tag of its live entity's Away Status, read when the line is -/// relayed (cm:Player.cpp:16571-16599). A creature's line carries none. -pub(crate) fn chat_event_outbound( - coord: &Coordinator, - self_guid: u64, - row: &ChatEvent, -) -> Vec { - let guard = coord.0.coord(); - let entities = guard.conn.db.game_world_entity(); - let speaker = entities.guid().find(&row.sender_guid); - if row.sender_guid != self_guid { - let Some(speaker) = &speaker else { - return Vec::new(); - }; - let Some(listener) = entities.guid().find(&self_guid) else { - return Vec::new(); - }; - let range_yd = chat_range_yd(row.chat_type); - if !chat_in_range( - speaker.map_id, - speaker.instance_id, - speaker.x, - speaker.y, - listener.map_id, - listener.instance_id, - listener.x, - listener.y, - range_yd * range_yd, - ) { - return Vec::new(); - } - if row.chat_type == lyracore_shared::chat::broadcast_chat::EMOTE - && !lyracore_shared::faction::same_team( - (speaker.unit_bytes_0 & 0xFF) as u8, - (listener.unit_bytes_0 & 0xFF) as u8, - ) - { - return Vec::new(); - } - } - let sender_name = if matches!(row.sender_guid >> 48, 0xF130 | 0xF140) { - let Some(speaker) = &speaker else { - return Vec::new(); - }; - let Some(template) = guard - .conn - .db - .game_creature_template() - .entry() - .find(&speaker.entry) - else { - return Vec::new(); - }; - Some(template.name) - } else { - None - }; - vec![chat_event_message( - row, - sender_name, - nearby_chat_tag(speaker.as_ref()), - )] -} - -/// The chat tag a nearby line carries: a player speaker's Away Status, and none for a creature or -/// a speaker whose entity is already gone. -fn nearby_chat_tag(speaker: Option<&WorldEntity>) -> u8 { - match speaker { - Some(entity) - if entity.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 => - { - lyracore_shared::chat::chat_tag_for(entity.player_flags) - } - _ => lyracore_shared::chat::chat_tag::NONE, - } -} - -fn chat_event_message(row: &ChatEvent, sender_name: Option, chat_tag: u8) -> Outbound { - let message = codec::build_chat_message_to( - row.sender_guid, - sender_name, - row.target_guid, - row.chat_type, - row.language, - row.message.clone(), - chat_tag, - ); - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(message))) -} - -/// Text emotes (`/wave`, `/dance`, …): SMSG_TEXT_EMOTE + SMSG_EMOTE, `chat_in_range`-gated at -/// [`lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD`] like say (cm:ChatHandler.cpp:789- -/// 794 sends to both teams, so there is no team gate here — contrast `chat_event_outbound`'s EMOTE -/// arm). The performer always sees their own emote. The target name resolves through the -/// coordinator cache (player, else creature template); unknown ids degrade gracefully so the rest -/// still relays. -pub(crate) fn emote_event_outbound( - coord: &Coordinator, - self_guid: u64, - row: &EmoteEvent, -) -> Vec { - if row.sender_guid != self_guid { - let guard = coord.0.coord(); - let speaker = match guard - .conn - .db - .game_world_entity() - .guid() - .find(&row.sender_guid) - { - Some(e) => e, - None => return Vec::new(), - }; - let listener = match guard.conn.db.game_world_entity().guid().find(&self_guid) { - Some(e) => e, - None => return Vec::new(), - }; - let range_yd = lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD; - if !chat_in_range( - speaker.map_id, - speaker.instance_id, - speaker.x, - speaker.y, - listener.map_id, - listener.instance_id, - listener.x, - listener.y, - range_yd * range_yd, - ) { - return Vec::new(); - } - } - // target_guid == 0 → untargeted emote (the client sends 0 when nothing is selected). - let target_name = if row.target_guid != 0 { - let guard = coord.0.coord(); - let db = &guard.conn.db; - db.game_character() - .guid() - .find(&row.target_guid) - .map(|c| c.name) - .or_else(|| { - db.game_world_entity() - .guid() - .find(&row.target_guid) - .and_then(|e| { - db.game_creature_template() - .entry() - .find(&e.entry) - .map(|c| c.name) - }) - }) - } else { - None - }; - let mut out = Vec::new(); - if let Some(m) = - codec::build_text_emote(row.sender_guid, row.text_emote, row.emote_anim, target_name) - { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_TEXT_EMOTE( - Box::new(m), - ))); - } - if let Some(a) = codec::build_emote_anim(row.sender_guid, row.emote_anim) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_EMOTE(Box::new(a)))); - } - out -} - -/// Dynamic objects: the shared-dispatch twin of the per-player -/// `on_dynobj_insert` relay. The instance gate already ran in `world_view::dynobj_appeared`. -pub(crate) fn relay_dynobj_create(row: &DynamicObject) -> Vec { - let m = codec::build_dynamicobject_create_object( - row.guid, - row.caster_guid, - row.spell_id, - row.x, - row.y, - row.z, - row.radius_yd, - ); - vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(m), - ))] -} - -/// A bare SMSG_DESTROY_OBJECT — the delete leg of the dynobj/corpse families, shared because the -/// packet is nothing but the guid. -pub(crate) fn relay_destroy_object(guid: u64) -> Vec { - vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(guid), - ))] -} - -/// Skill pane: the shared-dispatch twin of the per-player skill relays -/// (insert and update ran the same body there too). Audience (owner only) resolved by the -/// owner-session lookup in `world_view::skill_changed`; the slot allocation writes the viewer's -/// OWN `skill_slots` — the same map the per-player leg captures, seeded from the login layout. -pub(crate) fn relay_skill( - viewer: &Viewer, - skill_line: u32, - current: u16, - max_rank: u16, -) -> Vec { - let mut guard = viewer.skill_slots.lock().unwrap(); - let (map, next_free) = &mut *guard; - let slot = *map.entry(skill_line).or_insert_with(|| { - let s = *next_free; - *next_free += 1; - s - }); - if let Ok(sk) = wow_world_messages::vanilla::Skill::try_from(skill_line) { - if let Some(m) = codec::build_skill_values(viewer.self_guid, slot, sk, current, max_rank) { - return vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(m), - ))]; - } - } - Vec::new() -} - -pub(crate) fn motion_outbound( - created: &Mutex>, - self_guid: u64, - row: &EntityMotion, -) -> Vec { - MOTION_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if row.guid == self_guid || !created.lock().unwrap().contains(&row.guid) { - return Vec::new(); - } - match codec::build_movement_relay_raw(row.opcode as u32, row.guid, &row.movement_info) { - Some((opcode, body)) => { - MOTION_SENT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - vec![Outbound::Raw { opcode, body }] - } - None if lyracore_shared::opcodes::movement::is_slice_move(row.opcode as u32) => { - log::warn!( - "motion relay: malformed movement_info ({} bytes) for mover {}", - row.movement_info.len(), - row.guid - ); - Vec::new() - } - None => Vec::new(), - } -} - -/// One creature's spline row → `SMSG_MONSTER_MOVE` for one session. -#[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ - dropping the result is a silently invisible peer, which is the exact class the \ - shared-dispatch path's differential test exists to prevent"] -pub(crate) fn creature_leg_outbound( - created: &Mutex>, - row: &CreatureSpline, -) -> Vec { - if !created.lock().unwrap().contains(&row.guid) { - return Vec::new(); - } - let start = Vector3d { - x: row.sx, - y: row.sy, - z: row.sz, - }; - // a facing-only leg (the mover doesn't move, `emit_facing_spline`'s carrier) gets the - // `FacingAngle` variant — the only 1.12 `SMSG_MONSTER_MOVE` shape that turns a STATIONARY - // creature without a spline the client would otherwise have nothing to interpolate from. - if row.facing { - return vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( - Box::new(codec::build_monster_move_facing( - row.guid, - start, - row.facing_angle, - row.spline_id, - )), - ))]; - } - if let Some(path) = &row.path { - let points = &path.points; - let points: Vec<_> = points.iter().map(|p| (p.x, p.y, p.z)).collect(); - if let Some((opcode, body)) = codec::build_ground_path_raw( - row.guid, - start, - &points, - row.dur_ms, - row.spline_id, - row.run, - ) { - return vec![Outbound::Raw { opcode, body }]; - } - log::warn!( - "refused malformed creature path for guid {} spline {}", - row.guid, - row.spline_id - ); - if [start.x, start.y, start.z].iter().all(|v| v.is_finite()) { - return vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( - Box::new(codec::build_monster_move( - row.guid, - start, - start, - 0, - row.spline_id, - false, - )), - ))]; - } - return Vec::new(); - } - let dest = Vector3d { - x: row.dx, - y: row.dy, - z: row.dz, - }; - vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( - Box::new(codec::build_monster_move( - row.guid, - start, - dest, - row.dur_ms, - row.spline_id, - row.run, - )), - ))] -} - -fn append_resident_creature_after_create( - created_outbound: &mut Vec, - created: &Mutex>, - viewer_instance_id: u64, - row: Option<&CreatureSpline>, - now_micros: u64, -) { - let Some(row) = row else { - return; - }; - if row.instance_id != viewer_instance_id { - return; - } - if row.facing { - created_outbound.extend(creature_leg_outbound(created, row)); - return; - } - - let duration_micros = u64::from(row.dur_ms) * 1_000; - let elapsed_micros = now_micros.saturating_sub(row.start_micros); - if duration_micros == 0 || elapsed_micros >= duration_micros { - return; - } - - let elapsed_fraction = elapsed_micros as f64 / duration_micros as f64; - let current = |start: f32, destination: f32| { - (f64::from(start) + (f64::from(destination) - f64::from(start)) * elapsed_fraction) as f32 - }; - let mut remaining = row.clone(); - remaining.sx = current(row.sx, row.dx); - remaining.sy = current(row.sy, row.dy); - remaining.sz = current(row.sz, row.dz); - if let Some(path) = &row.path { - let points = &path.points; - let points: Vec<_> = points.iter().map(|p| (p.x, p.y, p.z)).collect(); - let (at, next) = lyracore_shared::movement_path::sample( - (row.sx, row.sy, row.sz), - &points, - elapsed_fraction as f32, - ); - (remaining.sx, remaining.sy, remaining.sz) = at; - if let Some(path) = &mut remaining.path { - path.points = path.points[next..].to_vec(); - } - } - remaining.dur_ms = (duration_micros - elapsed_micros).div_ceil(1_000) as u32; - created_outbound.extend(creature_leg_outbound(created, &remaining)); -} - -pub(crate) fn unix_now_micros() -> u64 { - let micros = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_micros(); - micros.min(u128::from(u64::MAX)) as u64 -} - -/// One module-owned passenger route to self and observers. Ordinary player motion deliberately -/// excludes self; taxi presentation must include it because the client is not driving the mover. -pub(crate) fn taxi_spline_outbound( - created: &Mutex>, - self_guid: u64, - row: &TaxiPassengerSpline, -) -> Vec { - if row.character_guid != self_guid && !created.lock().unwrap().contains(&row.character_guid) { - return Vec::new(); - } - let points: Vec = row - .points - .chunks_exact(3) - .map(|point| Vector3d { - x: point[0], - y: point[1], - z: point[2], - }) - .collect(); - if points.is_empty() || points.len() * 3 != row.points.len() { - log::warn!( - "taxi spline: malformed route ({} coordinates) for passenger {}", - row.points.len(), - row.character_guid - ); - return Vec::new(); - } - let start = Vector3d { - x: row.start_x, - y: row.start_y, - z: row.start_z, - }; - codec::build_taxi_move_raw( - row.character_guid, - start, - points, - row.duration_ms, - row.spline_id, - ) - .map_or_else(Vec::new, |(opcode, body)| { - vec![Outbound::Raw { opcode, body }] - }) -} - -fn append_resident_taxi_after_create( - created_outbound: &mut Vec, - created: &Mutex>, - self_guid: u64, - row: &TaxiPassengerSpline, -) { - created_outbound.extend(taxi_spline_outbound(created, self_guid, row)); -} - -/// Replay a resident passenger spline after the owner's self CREATE/login batch. The same helper -/// is used by reconnect recovery, keeping the CREATE-before-MONSTER_MOVE ordering explicit. -pub(crate) fn resident_taxi_spline_outbound( - coord: &Coordinator, - viewer: &Viewer, - character_guid: u64, -) -> Vec { - let guard = coord.0.coord(); - let Some(row) = guard - .conn - .db - .game_taxi_passenger_spline() - .character_guid() - .find(&character_guid) - else { - return Vec::new(); - }; - taxi_spline_outbound(&viewer.created, viewer.self_guid, &row) -} - -/// The `on_update` dispatch decision: does this update need to be treated as a -/// re-entry (offer a fresh CREATE via `offer_peer_create`) instead of a field diff? True exactly when -/// the guid isn't self AND isn't already in the viewer's `created` set — i.e. either this is the first -/// time this viewer has seen the row, or it left scope (evicted by `on_delete`) and is now returning as -/// an UPDATE of the SDK's still-cached row instead of a fresh `on_insert`. Pure over the dedup-set -/// membership so the branch is unit-testable without a live SDK connection. -fn is_update_reentry(guid: u64, self_guid: u64, currently_shown: bool) -> bool { - guid != self_guid && !currently_shown -} - -/// The SAY/YELL proximity gate: does a speaker at `(speaker_map, speaker_instance, -/// speaker_x, speaker_y)` reach a listener at `(listener_map, listener_instance, listener_x, -/// listener_y)` within `range_sq` (already squared — pass `SAY_RANGE_SQ`/`YELL_RANGE_SQ`)? Different -/// maps or different instances never hear each other (also guards the distance arithmetic against -/// comparing positions on unrelated maps). Pure — no I/O — so it is the ONE tested source of truth -/// for `on_chat`'s range gate. -#[allow(clippy::too_many_arguments)] -pub(crate) fn chat_in_range( - speaker_map: u32, - speaker_instance: u64, - speaker_x: f32, - speaker_y: f32, - listener_map: u32, - listener_instance: u64, - listener_x: f32, - listener_y: f32, - range_sq: f32, -) -> bool { - if speaker_map != listener_map || speaker_instance != listener_instance { - return false; - } - let dx = speaker_x - listener_x; - let dy = speaker_y - listener_y; - dx * dx + dy * dy <= range_sq -} - -/// Edge-detect for the spirit-healer reveal: did the GHOST player-flag (mask) cross 0↔1 between `old` -/// and `new`? Returns `Some(true)` on an alive→ghost crossing (REVEAL the in-scope spirit healers), -/// `Some(false)` on ghost→alive (DESTROY them), `None` when the bit didn't change (the common heartbeat / -/// any other field update — no reveal work). Pure so the on_update closure stays a thin relay and this -/// stays unit-testable (the closure itself needs a live EventContext to enumerate healers). -pub(crate) fn ghost_transition(old_flags: u32, new_flags: u32, ghost_mask: u32) -> Option { - let was = old_flags & ghost_mask != 0; - let is = new_flags & ghost_mask != 0; - if was == is { - None - } else { - Some(is) - } -} - -/// Diff an observed `game_world_entity` row (`old` → `new`) into the partial-VALUES `SMSG_UPDATE_OBJECT` -/// packets that replicate the change to this player's client. Pure (no I/O) so it is unit-testable — the -/// `on_update` relay callback just sends whatever this returns, in order. The level-up branch is -/// MUTUALLY EXCLUSIVE with the individual field diffs: a ding pushes ONE combined VALUES block (level + -/// vitals + xp together) instead of the separate health/xp packets, so the panel + bars flip in lockstep. -/// Player-only fields (xp / coinage / ghost / power) are gated on the PLAYER bit; health and dynamic-flags -/// apply to any unit (creatures' health bars + loot sparkle). Order is preserved from the original relay. -#[cfg(test)] -pub(crate) fn entity_update_to_outbound( - old: &WorldEntity, - new: &WorldEntity, -) -> Vec { - entity_update_to_outbound_with_dynamic_flags(old, new, new.dynamic_flags) -} - -fn entity_update_to_outbound_with_dynamic_flags( - old: &WorldEntity, - new: &WorldEntity, - dynamic_flags: u32, -) -> Vec { - let mut out = Vec::new(); - let is_player = new.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0; - if is_player && old.level != new.level { - // Level-up ding: level + maxhealth + health + xp in ONE VALUES block (the panel + health/XP bars - // flip together). SMSG_LEVELUP_INFO (relayed separately) is the cosmetic popup only. - let m = codec::build_levelup_values( - new.guid, - new.level, - new.health, - new.max_health, - new.xp, - new.next_level_xp, - (new.unit_bytes_0 >> 24) as u8, - new.max_power, - ); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } else { - if old.health != new.health { - let m = codec::build_health_values(new.guid, new.health); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Dynamic-flags VALUES relay: the killing blow sets UNIT_DYNFLAG_LOOTABLE (0x1) on a corpse that - // rolled money; the loot reducer clears it. NOTE: UNIT_DYNFLAG_DEAD (0x20) is NEVER set - // (it is feign-death in vanilla — see combat + lyracore-shared constants). - if old.dynamic_flags != new.dynamic_flags { - let m = codec::build_dynamic_flags_values(new.guid, dynamic_flags); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Target relay (UNIT_FIELD_TARGET): observers see who this unit selected — the target ring / - // target-of-target / assist. Any unit (a creature's aggro target relays too), not player-gated. - if old.target_guid != new.target_guid { - let m = codec::build_target_values(new.guid, new.target_guid); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Max-vitals relay: a non-level-up max_health/max_power change (+Stamina/+Intellect gear or a - // Fortitude/Mark aura) moves the bar DENOMINATOR live for observers. Any unit. - if old.max_health != new.max_health || old.max_power != new.max_power { - let power_b = (new.unit_bytes_0 >> 24) as u8; - let m = - codec::build_max_vitals_values(new.guid, new.max_health, power_b, new.max_power); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Taxi presentation changes mount display + the TAXI_FLIGHT unit flag in one mask (activation - // and landing set both atomically), so the client never sees an intermediate - // mounted-but-controllable or unmounted-but-in-flight state — detected by the flight bit itself - // toggling, not just "unit_flags changed", so an unrelated flag change alongside a land mount - // doesn't get mistaken for a taxi transition. A LAND mount/dismount changes `mount_display_id` - // ALONE (the flight bit is untouched) and relays through the standalone single-field builder - // instead, never alongside the coupled one for the same change. - let taxi_flight_toggled = (old.unit_flags ^ new.unit_flags) - & lyracore_shared::constants::unit_flags::TAXI_FLIGHT - != 0; - if old.mount_display_id != new.mount_display_id && taxi_flight_toggled { - let m = codec::build_taxi_presentation_values( - new.guid, - new.mount_display_id, - new.unit_flags, - ); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } else { - if old.mount_display_id != new.mount_display_id { - let m = codec::build_mount_display_values(new.guid, new.mount_display_id); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - if old.unit_flags != new.unit_flags { - let m = codec::build_unit_flags_values(new.guid, new.unit_flags); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - } - // Sheath relay: UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. - // Any unit, not player-gated — a creature drawing on engage is the same wire field. Without - // this arm the server knows the state and no observer ever hears about it. - if old.unit_bytes_2 != new.unit_bytes_2 { - let m = codec::build_sheath_values(new.guid, new.unit_bytes_2); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Live XP-bar update: players only (creatures never change xp; player-only fields). - let xp_changed = old.xp != new.xp || old.next_level_xp != new.next_level_xp; - if xp_changed && is_player { - let m = codec::build_player_xp_values(new.guid, new.xp, new.next_level_xp); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Live purse update: PLAYER_FIELD_COINAGE on money change (looting). Players only. - if old.money != new.money && is_player { - let m = codec::build_coinage_values(new.guid, new.money); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Live bank bag slot count: PLAYER_BYTES_2 byte 2 on a purchase, so the bank window grows a - // slot without a relog. Scoped to byte 2 only — byte 3 (rest state) already relays through - // its own dedicated `game_rest_state_event` path and must not double-fire here. - const BANK_SLOT_BYTE: u32 = 0x00FF_0000; - if is_player - && (old.player_bytes_2 & BANK_SLOT_BYTE) != (new.player_bytes_2 & BANK_SLOT_BYTE) - { - let m = codec::build_bank_bag_slots_values(new.guid, new.player_bytes_2); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // PLAYER_FLAGS and the UNIT_FIELD_BYTES_1 vis bit: the ghost transition on Release Spirit - // and reclaim, and the AFK and DND bits of an Away Status. Players only. - if is_player - && (old.player_flags != new.player_flags || old.unit_bytes_1 != new.unit_bytes_1) - { - let m = codec::build_player_flags_values(new.guid, new.player_flags, new.unit_bytes_1); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // Live power update: rage/energy/mana changing in combat. The power-type byte of unit_bytes_0 picks - // the right UNIT_FIELD_POWER_N. Players only — else the rage bar stays at 0 and rage spells grey out. - if is_player && old.power != new.power { - let power_b = (new.unit_bytes_0 >> 24) as u8; - let m = codec::build_power_values(new.guid, power_b, new.power); - out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); - } - // GM playtest `.speed`: a `run_speed_mult_bp` change relays - // SMSG_FORCE_RUN_SPEED_CHANGE — player movement is client-authoritative, so the server-side - // field alone never speeds the client up. Mirrors the existing `A_MOD_SPEED(MOVE)` aura relay - // (`run_speed_packet`, same `BASE_RUN_SPEED` constant) but keyed off THIS field, any unit (not - // player-gated — a GM could in principle god/speed-tag a creature down the line, and the relay - // is inert for anyone not watching this guid). - if old.run_speed_mult_bp != new.run_speed_mult_bp { - const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s — same constant as `run_speed_packet` - let speed = BASE_RUN_SPEED * (new.run_speed_mult_bp as f32 / 10_000.0); - out.push(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( - codec::build_force_run_speed(new.guid, speed), - ))); - } - } - out -} - -/// SMSG_UPDATE_AURA_DURATION (buff timer): the 1.12 UpdateMask aura array carries NO duration, -/// so without this the client shows "0 seconds"/flashing icon. Only for THIS player's own auras -/// (the slot indexes the player's aura array) with a finite window; the duration is the full -/// applied span (expires - applied), which is what the client shows on apply/refresh. Pure → -/// unit-tested (hoisted verbatim out of `subscribe_player_events`, no logic change). -fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option { - if row.target_guid != self_guid { - return None; - } - // i64::MAX is the infinite-duration sentinel; no timer packet for toggle auras. - if row.expires_at.to_micros_since_unix_epoch() == i64::MAX { - return None; - } - let ms = (row.expires_at.to_micros_since_unix_epoch() - - row.applied_at.to_micros_since_unix_epoch()) - / 1000; - if ms <= 0 { - return None; - } - Some(Outbound::One( - ServerOpcodeMessage::SMSG_UPDATE_AURA_DURATION(codec::build_aura_duration( - row.slot, ms as u32, - )), - )) -} - -/// Sprint / move-speed: a player's `A_MOD_SPEED(MOVE)` aura changing must push -/// SMSG_FORCE_RUN_SPEED_CHANGE — player movement is client-authoritative, so the server-side aura -/// alone never speeds the client up. Recompute the SUMMED move-speed % from the player's CURRENT -/// auras (post-change — on_delete fires after the row is gone — so apply AND expire both land the -/// right speed). Returns None unless `changed` is a move-speed aura on THIS player, so ordinary -/// buffs/debuffs don't spam the opcode. Pure → unit-tested (hoisted verbatim out of -/// `subscribe_player_events`, no logic change). -fn run_speed_packet( - auras: impl Iterator, - changed: &Aura, - self_guid: u64, -) -> Option { - const A_MOD_SPEED: u8 = 0xA4; // taxonomy A_MOD_SPEED - const SPEED_MOVE: i32 = 0; // taxonomy SPEED_MOVE (eff_p0) - const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s - if changed.target_guid != self_guid - || changed.eff_kind != A_MOD_SPEED - || changed.eff_p0 != SPEED_MOVE - { - return None; - } - let pct: i32 = auras - .filter(|a| { - a.target_guid == self_guid && a.eff_kind == A_MOD_SPEED && a.eff_p0 == SPEED_MOVE - }) - .map(|a| a.amount) - .sum(); - let speed = BASE_RUN_SPEED * (1.0 + pct as f32 / 100.0); - Some(Outbound::One( - ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new(codec::build_force_run_speed( - self_guid, speed, - ))), - )) -} - -/// Build the full quest-log update from the coordinator cache. The empty mask deliberately clears -/// every quest-log field when the final quest leaves the log. -pub(crate) fn quest_log_outbound( - db: &crate::stdb::bindings::RemoteTables, - self_guid: u64, -) -> Option { - if !crate::config::quest_log_fields_enabled() { - return None; - } - let quests: Vec<_> = db.game_character_quest().iter().collect(); - let slots = super::reads::build_quest_log_slots(db, &quests, self_guid); - let mask = codec::update_mask::full_quest_log_mask(&slots); - let (opcode, body) = codec::build_values_update_raw(self_guid, &mask); - Some(Outbound::Raw { opcode, body }) -} - -/// Build all packets for one quest update, in the order the client expects. -pub(crate) fn quest_update_outbound( - db: &crate::stdb::bindings::RemoteTables, - self_guid: u64, - old: &CharacterQuest, - row: &CharacterQuest, -) -> Vec { - let objectives: Vec<_> = db - .game_quest_objective() - .iter() - .filter(|objective| objective.quest_entry == row.quest_entry) - .map(|objective| { - ( - objective.kind, - objective.obj_index, - objective.target_entry, - objective.required_count, - ) - }) - .collect(); - quest_update_packets(old, row, &objectives, quest_log_outbound(db, self_guid)) -} - -/// Build the feedback that precedes a full quest-log sync for one update. -pub(crate) fn quest_update_feedback( - old: &CharacterQuest, - row: &CharacterQuest, - objectives: &[(u8, u8, u32, u32)], -) -> Vec { - let mut outbound = - codec::kill_progress_add_kills(row.quest_entry, &old.counts, &row.counts, objectives) - .into_iter() - .map(|packet| { - Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_KILL(Box::new( - packet, - ))) - }) - .collect::>(); - if !old.failed && row.failed { - outbound.push(Outbound::One( - ServerOpcodeMessage::SMSG_QUESTUPDATE_FAILEDTIMER( - codec::build_questupdate_failedtimer(row.quest_entry), - ), - )); - } - outbound -} - -/// Append a post-update full-log sync after kill and timer feedback. -pub(crate) fn quest_update_packets( - old: &CharacterQuest, - row: &CharacterQuest, - objectives: &[(u8, u8, u32, u32)], - quest_log: Option, -) -> Vec { - let mut outbound = quest_update_feedback(old, row, objectives); - if let Some(packet) = quest_log { - outbound.push(packet); - } - outbound -} - -fn item_gain_feedback( - db: &RemoteTables, - self_guid: u64, - slot: u8, - entry: u32, - gained: u32, - stack_add: bool, - random_property_id: u32, -) -> Vec { - if gained == 0 { - return Vec::new(); - } - let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT( - Box::new(codec::build_item_push_result( - self_guid, - codec::bag_content_parts(slot).map_or(255, |(bag, _)| bag), - u32::from(codec::bag_content_parts(slot).map_or(slot, |(_, position)| position)), - entry, - gained, - stack_add, - random_property_id, - )), - ))]; - let wanted = db.game_character_quest().iter().any(|q| { - q.character_guid == self_guid - && !q.rewarded - && !q.failed - && db - .game_quest_objective() - .iter() - .any(|o| o.quest_entry == q.quest_entry && o.kind == 1 && o.target_entry == entry) - }); - if wanted { - use wow_world_messages::vanilla::SMSG_QUESTUPDATE_ADD_ITEM; - out.push(Outbound::One( - ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_ITEM(SMSG_QUESTUPDATE_ADD_ITEM { - required_item_id: entry, - items_required: gained, - }), - )); - } - out +/// Trailing fan-out baseline + the length of the current run of collapsed windows. Lives in the +/// 10-second `MOTIONSTAT` task; nothing on the relay hot path touches it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct FanoutHealth { + /// `None` until the first informative window establishes it. + pub(crate) baseline: Option, + pub(crate) low_windows: u32, } -/// Build the complete live-inventory insert result for one owner. -pub(crate) fn item_instance_insert_outbound( - db: &RemoteTables, - self_guid: u64, - row: &ItemInstance, - enchantment: u32, -) -> Vec { - let mut out = Vec::new(); - let (max_durability, container_slots) = db - .game_item_template() - .entry() - .find(&row.entry) - .map(|t| (t.max_durability, t.container_slots)) - .unwrap_or((row.durability, 0)); - let view = codec::ItemInstanceView { - max_durability, - container_slots, - random_property_enchant_ids: super::reads::property_enchant_ids(db, row.random_property_id), - enchantment, - ..view_of_item_row(row) +/// One 10-second window of the fan-out health check — pure, so the decision is unit-tested directly +/// instead of being eyeballed in a log. Returns the next state, and +/// `Some(consecutive_collapsed_windows)` when this window should warn (`None` = stay quiet). +/// +/// **What it catches.** Peer movement being delivered to fewer observers than it was a moment ago, +/// while players are still moving — the 371-client shape, where nothing errored, nothing logged, and +/// peers simply updated less often than they moved. `delivery` (= sent/calls) read ~100 % throughout +/// that run, because the shortfall was `calls` never reaching the rate it should have; `fanout` is +/// the field that showed it, and this turns that field from "a number an operator has to know the +/// expected value of" into a warning. +/// +/// **What it does NOT catch — read this before trusting it.** +/// * **A gateway that has been degraded since startup.** The baseline is the gateway's OWN recent +/// behaviour, so if fan-out was already wrong when the first window was measured, that wrong value +/// IS the baseline and nothing ever trips. This check reports a CHANGE, never an absolute truth. +/// Nothing in the gateway currently knows how many observers a mover *should* have reached — +/// the AOI box is per-session, invisible to this task, and `GridBox` has no +/// overlap test, so a true expectation needs a global registry of per-session boxes plus box +/// intersection. That is the honest version of this warning and it is not what this is. +/// * **A slow slide.** A degradation that takes minutes to develop keeps each window within +/// [`FANOUT_COLLAPSE_RATIO`] of the last, and the baseline follows it down. Only a step change of +/// ≥40 % inside ~30 s trips. +/// * **A collapse that starts during a quiet spell.** Windows below +/// [`crate::world::MOVE_ACTIVITY_FLOOR`] submitted movements are treated as uninformative: they do +/// not warn, and they break a run in progress. +/// * **Under-delivery to SOME sessions.** Both counters are process-global, so one wedged session +/// among 300 healthy ones is invisible here. +/// +/// A TOTAL loss of the relay trips this as well as the stuck-relay signal (fan-out goes to ~0, which is +/// certainly below any baseline) — two lines about one failure, 30 s apart, which is the right way +/// round: the stuck-relay signal is instant and keeps repeating, this one adds "and it stayed that way". +pub(crate) fn fanout_health_step( + state: FanoutHealth, + fanout: Option, + submitted_delta: u64, +) -> (FanoutHealth, Option) { + // Uninformative window: too little movement to compare, or none at all. Keep the baseline (a + // quiet window is not evidence about fan-out) but break any run in progress — the same activity + // floor the stuck-relay signal uses, for the same reason: a handful of movements with little relay + // traffic is one lonely player, not a broken relay. + let Some(f) = fanout.filter(|_| submitted_delta > crate::world::MOVE_ACTIVITY_FLOOR) else { + return ( + FanoutHealth { + low_windows: 0, + ..state + }, + None, + ); }; - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_item_create_object(&view)), - ))); - if let Some((opcode, body)) = codec::build_random_property_values(&view) { - out.push(Outbound::Raw { opcode, body }); - } - if let Some(values) = codec::build_inv_slot_values(self_guid, row.slot, row.guid) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, &view) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { - if let Some(bag) = db - .game_item_instance() - .iter() - .find(|item| item.owner_guid == self_guid && item.slot == bag_slot) - { - let (opcode, body) = - codec::build_container_slot_values(bag.guid, slot_in_bag, row.guid); - out.push(Outbound::Raw { opcode, body }); - } - } - // The item object and the descriptor that makes it reachable must precede gain feedback. The - // shared owner-view enqueues this complete vector as one writer job, so another producer cannot - // splice quest completion between CREATE and its inventory/container pointer. - out.extend(item_gain_feedback( - db, - self_guid, - row.slot, - row.entry, - row.stack_count, - false, - row.random_property_id, - )); - if row.slot <= 18 { - append_item_armor_and_sheet(db, self_guid, &mut out); - } - out -} - -/// Build the complete live-inventory delete result for one owner. -pub(crate) fn item_instance_delete_outbound( - db: &RemoteTables, - self_guid: u64, - row: &ItemInstance, -) -> Vec { - let mut out = Vec::new(); - if let Some(values) = codec::build_inv_slot_values(self_guid, row.slot, 0) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - if let Some(values) = - codec::build_visible_item_values(self_guid, row.slot, &codec::ItemInstanceView::default()) - { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { - if let Some(bag) = db - .game_item_instance() - .iter() - .find(|item| item.owner_guid == self_guid && item.slot == bag_slot) - { - let (opcode, body) = codec::build_container_slot_values(bag.guid, slot_in_bag, 0); - out.push(Outbound::Raw { opcode, body }); - } - } - out.push(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(row.guid), - ))); - if row.slot <= 18 { - append_item_armor_and_sheet(db, self_guid, &mut out); - } - out -} - -/// Build the complete live-inventory update result for one owner. -pub(crate) fn item_instance_update_outbound( - db: &RemoteTables, - self_guid: u64, - old: &ItemInstance, - row: &ItemInstance, -) -> Vec { - let old_slot_is_empty = old.slot != row.slot - && !db - .game_item_instance() - .iter() - .any(|item| item.owner_guid == self_guid && item.slot == old.slot); - let mut out = if row.slot == old.slot && row.stack_count > old.stack_count { - item_gain_feedback( - db, - self_guid, - row.slot, - row.entry, - row.stack_count - old.stack_count, - true, - row.random_property_id, - ) - } else { - Vec::new() + let Some(baseline) = state.baseline else { + // First informative window: nothing to compare against yet. + return ( + FanoutHealth { + baseline: Some(f), + low_windows: 0, + }, + None, + ); }; - if old.slot != row.slot { - append_final_item_slots(self_guid, old.slot, row.slot, &mut out, |slot| { - db.game_item_instance() - .iter() - .find(|item| item.owner_guid == self_guid && item.slot == slot) - .map(|item| view_of_item_row(&item)) - }); - } - out.extend(enchant_change_outbound(self_guid, old, row)); - if old.stack_count != row.stack_count || old.durability != row.durability { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_item_values( - row.guid, - row.stack_count, - row.durability, - )), - ))); - } - let moved_into_equipment = old.slot != row.slot && row.slot <= 18; - let emptied_equipment = old.slot != row.slot && old.slot <= 18 && old_slot_is_empty; - if moved_into_equipment - || emptied_equipment - || row.slot <= 18 - && ((old.durability == 0) != (row.durability == 0) || old.enchant_id != row.enchant_id) - { - append_item_armor_and_sheet(db, self_guid, &mut out); + if f >= baseline * FANOUT_COLLAPSE_RATIO { + // Healthy window — fold it into the trailing baseline and forget any partial run. + let next = baseline + FANOUT_BASELINE_ALPHA * (f - baseline); + return ( + FanoutHealth { + baseline: Some(next), + low_windows: 0, + }, + None, + ); } - out -} - -/// An in-place permanent enchant change: the item's enchantment word and, for worn gear that did -/// not change slot, its visible item. The slot projection covers a change of slot. Empty when the -/// enchant did not change, and for a Guild Charter, whose word holds its Petition id. -fn enchant_change_outbound( - self_guid: u64, - old: &ItemInstance, - row: &ItemInstance, -) -> Vec { - if old.enchant_id == row.enchant_id || row.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY - { - return Vec::new(); - } - let view = view_of_item_row(row); - let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_item_enchantment_values( - row.guid, - view.enchantment, - )), - ))]; - if old.slot == row.slot { - if let Some(values) = codec::build_visible_item_values(self_guid, row.slot, &view) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } + let low_windows = state.low_windows + 1; + let warn = (low_windows >= FANOUT_COLLAPSE_WINDOWS).then_some(low_windows); + if low_windows >= FANOUT_REBASELINE_WINDOWS { + // Warned for ~30 s and it is still low: accept this as the new normal (see + // FANOUT_REBASELINE_WINDOWS) — this window still warns, the next one will not. + return ( + FanoutHealth { + baseline: Some(f), + low_windows: 0, + }, + warn, + ); } - out + ( + FanoutHealth { + baseline: Some(baseline), + low_windows, + }, + warn, + ) } -fn append_final_item_slots( - self_guid: u64, - old_slot: u8, - new_slot: u8, - out: &mut Vec, - item_in_slot: impl Fn(u8) -> Option, -) { - let old_item = item_in_slot(old_slot); - let new_item = item_in_slot(new_slot).unwrap_or_default(); - let slots = old_item - .is_none() - .then_some((old_slot, codec::ItemInstanceView::default())) - .into_iter() - .chain([(new_slot, new_item)]); - for (slot, item) in slots.clone() { - if let Some(values) = codec::build_inv_slot_values(self_guid, slot, item.guid) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - } - for (slot, item) in slots.clone() { - if let Some(values) = codec::build_visible_item_values(self_guid, slot, &item) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))); - } - } - for (slot, item) in slots { - if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(slot) { - if let Some(bag) = item_in_slot(bag_slot) { - let (opcode, body) = - codec::build_container_slot_values(bag.guid, slot_in_bag, item.guid); - out.push(Outbound::Raw { opcode, body }); - } - } - } +/// **Should this peer-motion packet be dropped instead of queued?** The one decision behind the +/// per-session egress bound (task B2) — pure and unit-tested, called with `tx.depth()`. +/// +/// The channel behind [`crate::world::SessionTx`] is unbounded, so a session whose socket has +/// stopped taking bytes grows its backlog for as long as the stall lasts: a memory path under crowd +/// load, and the reason `MOTION_DROPPED` could not mean backpressure before this. Bounding the +/// CHANNEL is not available to us — ~110 of the send sites run inside SpacetimeDB SDK row callbacks +/// on the connection's pump thread, and a blocking send from there on a full channel would deadlock +/// that connection (see `SessionTx`'s own comment). So the queue stays unbounded and the only +/// traffic that can plausibly outrun a socket sheds itself. +/// +/// **Why only peer motion and creature legs, out of ~180 send sites.** They are the N²-shaped +/// traffic: one packet per OBSERVER per mover per heartbeat, ~740/s per session at 371 co-located +/// clients, versus a handful per player action for everything else. And they are the only sends that +/// are SELF-HEALING when lost — a movement heartbeat is superseded by the next one 500 ms later, so +/// a shed packet costs a moment of coarser interpolation on a peer that is already being rendered +/// from stale data (the queue is half a second deep). A quest-log page, an item CREATE, a +/// `SMSG_DESTROY_OBJECT` is not superseded by anything: dropping one leaves the client permanently +/// wrong. Those keep queueing exactly as before. +/// +/// Shedding is counted in [`MOTION_DROPPED`], so it is visible on the existing `MOTIONSTAT` line as +/// both a rising `dropped` and a falling `delivery` — a shed that fires is not silent. +pub(crate) fn shed_motion_at_depth(depth: usize) -> bool { + depth > crate::world::EGRESS_SHED_DEPTH } -fn append_item_armor_and_sheet(db: &RemoteTables, self_guid: u64, out: &mut Vec) { - let resistances = super::armor::effective_resistances(db, self_guid); - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_resistance_values(self_guid, resistances)), - ))); - if let Some(stats) = super::armor::sheet_stats(db, self_guid) { - out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_sheet_stats_values(self_guid, &stats)), - ))); - } -} impl Coordinator { pub fn spawn_party_command_relay(&self) { for shard in self.all_shards() { @@ -4563,6 +1193,8 @@ fn next_bot_transfer_claim_token() -> Result { #[cfg(test)] mod tests { use super::*; + use crate::codec::combat::subscriptions::tests::aura; + use crate::codec::entity::subscriptions::tests::{creature_entity, player_entity}; #[test] fn transfer_work_continues_after_the_prior_pass_then_wraps() { @@ -5576,285 +2208,6 @@ mod tests { assert_eq!((view.guild_id, view.guild_rank), (0, 0)); } - /// A live-entity row with no pending change; tests clone it and mutate one field at a time. - /// `type_mask` carries the PLAYER bit by default (most relayed fields are player-only). - fn player_entity() -> WorldEntity { - WorldEntity { - guid: 1, - owner_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - account_id: 1, - map_id: 0, - x: 0.0, - y: 0.0, - z: 0.0, - orientation: 0.0, - grid_x: 0, - grid_y: 0, - cell: lyracore_shared::spatial::grid_cell_id(0, 0), - last_move_ms: 0, - type_mask: lyracore_shared::constants::type_mask::PLAYER_BIT, - entry: 0, - scale_x: 1.0, - health: 100, - max_health: 100, - power: 100, - max_power: 100, - level: 1, - faction_template: 0, - unit_bytes_0: 0, - display_id: 0, - native_display_id: 0, - unit_flags: 0, - base_attack_time_ms: 2000, - dynamic_flags: 0, - dead: false, - player_bytes: 0, - player_bytes_2: 0, - player_bytes_3: 0, - player_flags: 0, - xp: 0, - next_level_xp: 100, - target_guid: 0, - money: 0, - unit_bytes_1: 0, - unit_bytes_2: 0, - strength: 1, - agility: 1, - stamina: 1, - intellect: 1, - spirit: 1, - npc_flags: 0, - armor: 0, - leg_ends_ms: 0, - wp_target: 0, - movement_flags: 0, - combat_until_ms: 0, - pickpocketed: false, - next_swing_spell: 0, - overpower_until_ms: 0, - revenge_until_ms: 0, - stance: 0, - owner_guid: 0, - skinned: false, - mana_regen_paused_until_ms: 0, - death_expire_micros: 0, - instance_id: 0, - run_speed_mult_bp: 10_000, - godmode: false, - resting: false, - sheet_str_bonus: 0, - sheet_agi_bonus: 0, - sheet_sta_bonus: 0, - sheet_int_bonus: 0, - sheet_spi_bonus: 0, - sheet_ap_base: 0, - sheet_ap_mods: 0, - sheet_dmg_min: 0, - sheet_dmg_max: 0, - sheet_crit_bp: 0, - bank_bag_slots: 0, - mount_display_id: 0, - zone_id: 0, - sheet_ranged_ap: 0, - sheet_ranged_dmg_min: 0, - sheet_ranged_dmg_max: 0, - } - } - - fn creature_entity() -> WorldEntity { - let mut entity = player_entity(); - entity.guid = 99; - entity.type_mask = lyracore_shared::constants::type_mask::CREATURE; - entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::TAPPED; - entity - } - - fn tap(character_guid: u64) -> CreatureQuestTap { - CreatureQuestTap { - creature_guid: 99, - character_guid, - } - } - - fn tag_member(character_guid: u64) -> CreatureQuestTapMember { - CreatureQuestTapMember { - id: character_guid, - creature_guid: 99, - character_guid, - } - } - - fn current_member(character_guid: u64, group_id: u64) -> GroupMember { - GroupMember { - id: character_guid, - group_id, - character_guid, - owner_identity: spacetimedb_sdk::Identity::from_byte_array([0; 32]), - raid_slot: 0, - } - } - - fn project_flags( - viewer_guid: u64, - entity: &WorldEntity, - tap: Option<&CreatureQuestTap>, - tag_group: Option<&CreatureLootTagGroup>, - tag_members: &[CreatureQuestTapMember], - current_members: &[GroupMember], - corpse_eligible: &[CorpseLootEligible], - ) -> u32 { - viewer_relative_dynamic_flags( - viewer_guid, - entity, - LootTagProjectionRows { - tap, - tag_group, - tag_members, - current_members, - corpse_eligible, - }, - ) - } - - #[test] - fn solo_tagger_sees_tapped_by_player() { - let entity = creature_entity(); - let tap = tap(7); - assert_eq!( - project_flags(7, &entity, Some(&tap), None, &[], &[], &[]), - entity.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER - ); - } - - #[test] - fn snapshot_group_member_sees_tapped_by_player() { - let entity = creature_entity(); - let tap = tap(7); - let group = CreatureLootTagGroup { - creature_guid: entity.guid, - group_id: 42, - }; - let members = [tag_member(7), tag_member(8)]; - let current = [current_member(8, 42)]; - assert_eq!( - project_flags( - 8, - &entity, - Some(&tap), - Some(&group), - &members, - ¤t, - &[] - ), - entity.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER - ); - } - - #[test] - fn stranger_sees_only_stored_tapped_flag() { - let entity = creature_entity(); - let tap = tap(7); - assert_eq!( - project_flags(8, &entity, Some(&tap), None, &[], &[], &[]), - entity.dynamic_flags - ); - } - - #[test] - fn later_group_joiner_sees_only_stored_tapped_flag() { - let entity = creature_entity(); - let tap = tap(7); - let group = CreatureLootTagGroup { - creature_guid: entity.guid, - group_id: 42, - }; - let members = [tag_member(7)]; - let current = [current_member(8, 42)]; - assert_eq!( - project_flags( - 8, - &entity, - Some(&tap), - Some(&group), - &members, - ¤t, - &[] - ), - entity.dynamic_flags - ); - } - - #[test] - fn group_leaver_loses_tapped_by_player() { - let entity = creature_entity(); - let tap = tap(7); - let group = CreatureLootTagGroup { - creature_guid: entity.guid, - group_id: 42, - }; - let members = [tag_member(7), tag_member(8)]; - let current = [current_member(8, 99)]; - assert_eq!( - project_flags( - 8, - &entity, - Some(&tap), - Some(&group), - &members, - ¤t, - &[] - ), - entity.dynamic_flags - ); - } - - #[test] - fn eligible_corpse_retains_lootable() { - let mut entity = creature_entity(); - entity.dead = true; - entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; - let eligible = [CorpseLootEligible { - id: 1, - corpse_guid: entity.guid, - eligible_guid: 7, - }]; - assert_eq!( - project_flags(7, &entity, None, None, &[], &[], &eligible), - entity.dynamic_flags - ); - } - - #[test] - fn foreign_viewer_does_not_see_corpse_lootable() { - let mut entity = creature_entity(); - entity.dead = true; - entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; - let eligible = [CorpseLootEligible { - id: 1, - corpse_guid: entity.guid, - eligible_guid: 7, - }]; - assert_eq!( - project_flags(8, &entity, None, None, &[], &[], &eligible), - 0 - ); - } - - #[test] - fn empty_corpse_eligibility_does_not_show_lootable() { - let mut entity = creature_entity(); - entity.dead = true; - entity.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; - assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0); - } - - #[test] - fn untagged_entity_flags_are_unchanged() { - let mut entity = creature_entity(); - entity.dynamic_flags = 0x42; - assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0x42); - } - #[test] fn no_change_emits_nothing() { let e = player_entity(); @@ -6393,52 +2746,6 @@ mod tests { ); } - /// A minimal `Aura` row for the packet-builder tests: `target_guid`/`eff_kind`/`eff_p0`/`amount` - /// are the fields those builders branch on; `applied_at`/`expires_at` (raw micros) drive the - /// duration math. Everything else is a fixed, inert value. - fn aura( - target_guid: u64, - eff_kind: u8, - eff_p0: i32, - amount: i32, - applied_at_micros: i64, - expires_at_micros: i64, - ) -> Aura { - Aura { - id: 1, - target_guid, - caster_guid: target_guid, - spell_id: 6673, - slot: 3, - level: 1, - flags: 0, - applied_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(applied_at_micros), - expires_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(expires_at_micros), - effect_id: 1, - eff_kind, - amount, - eff_p0, - eff_p0_kind: 0, - eff_p1: 0, - period_ms: 0, - amount_remaining: 0, - stacks: 1, - next_tick_micros: 0, - channel_target: 0, - enters_combat: false, - proc_flags: 0, - proc_chance: 0, - proc_ppm: 0.0, - proc_ex: 0, - proc_school_mask: 0, - proc_family_name: 0, - proc_family_flags: 0, - proc_charges: 0, - proc_icd_ms: 0, - proc_ready_micros: 0, - } - } - #[test] fn peer_reveal_restores_remaining_auras_after_create() { use codec::update_mask::idx; @@ -6526,50 +2833,6 @@ mod tests { )); } - #[test] - fn aura_duration_packet_pins_the_remaining_window_for_self_only() { - let self_guid = 1u64; - // A 5-second remaining window on the player's OWN aura, in slot 3. - let mine = aura(self_guid, 0xA1, 1, 10, 0, 5_000_000); - let out = aura_duration_packet(&mine, self_guid); - let m = match out { - Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_AURA_DURATION(m))) => m, - _ => panic!("expected Some(Outbound::One(SMSG_UPDATE_AURA_DURATION))"), - }; - assert_eq!(m, codec::build_aura_duration(3, 5000)); - // A peer's aura (not self) never gets a duration packet — the slot only indexes the - // viewer's OWN aura array. - assert!(aura_duration_packet(&mine, self_guid + 1).is_none()); - // The infinite-duration sentinel (a toggle aura) never gets a timer packet. - let toggle = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); - assert!(aura_duration_packet(&toggle, self_guid).is_none()); - // A non-positive remaining window (expires_at <= applied_at) emits nothing. - let expired = aura(self_guid, 0xA1, 1, 10, 5_000_000, 5_000_000); - assert!(aura_duration_packet(&expired, self_guid).is_none()); - } - - #[test] - fn run_speed_packet_sums_move_speed_auras_on_self_only() { - let self_guid = 1u64; - // Two move-speed auras on self (e.g. Sprint 25% + a totem 5%) sum into ONE force-speed packet. - let a1 = aura(self_guid, 0xA4, 0, 25, 0, i64::MAX); - let a2 = aura(self_guid, 0xA4, 0, 5, 0, i64::MAX); - let current = vec![a1.clone(), a2.clone()]; - let out = run_speed_packet(current.clone().into_iter(), &a1, self_guid); - let m = match out { - Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(m))) => m, - _ => panic!("expected Some(Outbound::One(SMSG_FORCE_RUN_SPEED_CHANGE))"), - }; - let expected_speed = 7.0f32 * (1.0 + 30_f32 / 100.0); // BASE_RUN_SPEED * (1 + summed pct) - assert_eq!(*m, codec::build_force_run_speed(self_guid, expected_speed)); - // A non-move-speed change on self spams nothing (ordinary buffs/debuffs don't trigger this). - let unrelated = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); - assert!(run_speed_packet(current.clone().into_iter(), &unrelated, self_guid).is_none()); - // A move-speed change on a DIFFERENT guid (a peer) never triggers self's packet. - let peer_changed = aura(self_guid + 1, 0xA4, 0, 25, 0, i64::MAX); - assert!(run_speed_packet(current.into_iter(), &peer_changed, self_guid).is_none()); - } - #[test] fn bag_content_parts_boundaries() { assert_eq!(codec::bag_content_parts(119), None); // just below the bag-content range diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 043f5b8fa..9b9099940 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -1,52 +1,118 @@ -//! The SHARED area-of-interest dispatch — one set of row callbacks per shard's -//! coordinator connection, routed to sessions through [`WorldIndex`]. +//! Shared viewer routing, AOI indexes and Relay queue lifetime. //! -//! # What replaced what -//! -//! Before: each player opened their own SpacetimeDB connection, subscribed a 5×5 grid box over -//! `game_world_entity` / `game_gameobject` / `game_entity_motion` / `game_creature_spline`, -//! re-subscribed on every cell crossing, and registered its own row callbacks on that connection. -//! ~600 unshareable query strings, ~600 SDK pumps woken per committed transaction. -//! -//! After: spatial, broadcast, private, and owner-addressed tables ride the coordinator's existing -//! global subscription (one per shard). The callbacks below are registered ONCE per shard. -//! [`WorldIndex`] answers spatial audience questions; typed owner and identity indexes select one -//! viewer for addressed rows. Every shard's stream feeds ONE view spanning the whole realm. -//! -//! # The two rules this file exists to keep -//! -//! **1. The pump does bookkeeping and ENQUEUE only.** The SDK forbids parallel -//! `advance_one_message` callers, so a shard's coordinator pump is a single thread that every -//! session now depends on. It must never encode a packet or take a session's lock for long: it -//! updates the index, asks it for recipients, and pushes a [`crate::world::Outbound::Job`] — a -//! closure that does the gating and encoding LATER, on that session's own writer thread. If relay -//! work ran here, one slow session would stall the whole realm instead of only itself. The row is -//! wrapped in an `Arc` once per delta so the fan-out is a refcount bump per recipient, not a clone. -//! -//! **2. A panicking callback must not take the realm down.** On a per-player connection a panicking -//! row callback killed one session. On a shared connection it would kill every session on that -//! shard, so every callback body here runs inside `catch_unwind`. -//! -//! # What did NOT move -//! -//! Every per-session visibility gate is unchanged and still evaluated per session, just later (in -//! the job) instead of on the pump: the `created` dedup set, the instance gate, the spirit-healer -//! ghost gate, and the stealth gate. The security posture is unchanged in kind — -//! these four tables were already read through an RLS-bypassing owner connection on the -//! coordinator; what is new is that the gateway, not the subscription engine, decides who sees -//! them, which is exactly the filtering responsibility the shared-connection model shifted here. +//! Protocol Families declare their table callbacks beside their codecs. Callbacks update indexes +//! and enqueue owned rows; the World Session writer builds packets. Arming registers callbacks +//! before reconciling the cache on the same Shard pump. + +#[cfg(test)] +use crate::codec::combat::subscriptions::{ + aura_audience, cast_audience, combat_audience, impact_audience, melee_audience, +}; +#[cfg(test)] +use crate::codec::item::subscriptions::ItemSlotRead; +#[cfg(test)] +use crate::codec::social::subscriptions::{chat_audience, emote_audience}; +#[cfg(test)] +use crate::codec::addon::subscriptions::addon_message_appeared; +#[cfg(test)] +use crate::codec::breath::subscriptions::breath_relay_appeared; +#[cfg(test)] +use crate::codec::channel::subscriptions::channel_notice_appeared; +#[cfg(test)] +use crate::codec::combat::subscriptions::cast_event_appeared; +#[cfg(test)] +use crate::codec::combat::subscriptions::combat_event_appeared; +#[cfg(test)] +use crate::codec::combat::subscriptions::impact_appeared; +#[cfg(test)] +use crate::codec::combat::subscriptions::melee_disengaged; +#[cfg(test)] +use crate::codec::combat::subscriptions::melee_engaged; +#[cfg(test)] +use crate::codec::corpse::subscriptions::resurrect_offered; +#[cfg(test)] +use crate::codec::corpse::subscriptions::self_res_option_changed; +#[cfg(test)] +use crate::codec::duel::subscriptions::duel_winner_audience; +use crate::codec::entity::subscriptions::entity_key; +#[cfg(test)] +use crate::codec::entity::subscriptions::entity_vanished; +use crate::codec::entity::subscriptions::offer_create_job; +#[cfg(test)] +use crate::codec::entity::subscriptions::reputation_appeared; +use crate::codec::gameobject::subscriptions::gameobject_appeared; +#[cfg(test)] +use crate::codec::gameobject::subscriptions::gameobject_vanished; +#[cfg(test)] +use crate::codec::guild::subscriptions::charter_petition_opened; +#[cfg(test)] +use crate::codec::guild::subscriptions::guild_event_appeared; +#[cfg(test)] +use crate::codec::guild::subscriptions::guild_membership_changed; +#[cfg(test)] +use crate::codec::guild::subscriptions::petition_event_appeared; +#[cfg(test)] +use crate::codec::item::subscriptions::item_owner_job; +#[cfg(test)] +use crate::codec::item::subscriptions::item_visible_changed; +#[cfg(test)] +use crate::codec::item::subscriptions::trade_event_appeared; +#[cfg(test)] +use crate::codec::leveling::subscriptions::exploration_outbound_for_word; +#[cfg(test)] +use crate::codec::leveling::subscriptions::levelup_appeared; +#[cfg(test)] +use crate::codec::leveling::subscriptions::rest_state_appeared; +#[cfg(test)] +use crate::codec::leveling::subscriptions::xp_appeared; +#[cfg(test)] +use crate::codec::mail::subscriptions::mail_arrived; +#[cfg(test)] +use crate::codec::movement::subscriptions::creature_leg; +#[cfg(test)] +use crate::codec::movement::subscriptions::motion; +#[cfg(test)] +use crate::codec::movement::subscriptions::teleport_appeared; +#[cfg(test)] +use crate::codec::social::subscriptions::character_online_changed; +#[cfg(test)] +use crate::codec::social::subscriptions::claim_closed; +#[cfg(test)] +use crate::codec::social::subscriptions::contact_changed; +#[cfg(test)] +use crate::codec::social::subscriptions::friend_notice_candidates; +#[cfg(test)] +use crate::codec::social::subscriptions::instance_removal_ended; +#[cfg(test)] +use crate::codec::social::subscriptions::instance_removal_started; +#[cfg(test)] +use crate::codec::social::subscriptions::instance_removal_time_left_ms; +#[cfg(test)] +use crate::codec::social::subscriptions::realm_chat_appeared; +use crate::codec::social::subscriptions::replace_contact_sets; +#[cfg(test)] +use crate::codec::social::subscriptions::resident_countdown_ms; +use crate::codec::social::subscriptions::resident_instance_removal_outbound; +#[cfg(test)] +use crate::codec::social::subscriptions::system_message_appeared; +#[cfg(test)] +use crate::codec::taxi::subscriptions::taxi_spline; +#[cfg(test)] +use crate::codec::trainer::subscriptions::skill_changed; +#[cfg(test)] +use crate::codec::weather::subscriptions::weather_changed; +#[cfg(test)] +use crate::codec::weather::subscriptions::zone_crossed; use std::collections::{HashMap, HashSet}; -use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; +#[cfg(test)] +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use anyhow::{anyhow, Result}; -use lyracore_shared::item_property::client_enchantment_id; use lyracore_shared::spatial::{BOX_HALF_SPAN, GRID_CELL_SIZE}; -use spacetimedb_sdk::{Table, TableWithPrimaryKey}; -use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -use wow_world_messages::vanilla::WeatherChangeType; +use spacetimedb_sdk::Table; use super::aoi::{ViewerGates, AOI_RECENTERS}; use super::bindings::*; @@ -121,7 +187,7 @@ impl Viewer { /// The unresolved zone (`0`) is not a crossing and never overwrites a known zone: it means the /// Module's terrain could not answer, not that the player left every zone, and treating it as a /// destination would send fine weather over a real storm. - fn enter_zone(&self, zone_id: u32) -> bool { + pub(crate) fn enter_zone(&self, zone_id: u32) -> bool { zone_id != 0 && self.zone_id.swap(zone_id, Ordering::Relaxed) != zone_id } @@ -133,7 +199,7 @@ impl Viewer { .contains(&guid) } - fn set_ignored(&self, guid: u64, ignored: bool) { + pub(crate) fn set_ignored(&self, guid: u64, ignored: bool) { let mut set = self.ignored.lock().unwrap_or_else(|p| p.into_inner()); if ignored { set.insert(guid); @@ -150,7 +216,7 @@ impl Viewer { .contains(&guid) } - fn set_friend(&self, guid: u64, present: bool) { + pub(crate) fn set_friend(&self, guid: u64, present: bool) { let mut set = self.friends.lock().unwrap_or_else(|p| p.into_inner()); if present { set.insert(guid); @@ -192,7 +258,7 @@ impl ExplorationReplay { self.announced.extend(area_bits); } - fn admit_once(&mut self, area_bit: i32) -> bool { + pub(crate) fn admit_once(&mut self, area_bit: i32) -> bool { self.announced.insert(area_bit) } } @@ -251,7 +317,7 @@ impl ViewerRegistry { .insert(session, RegisteredViewer { viewer, shard }); } - fn remove(&mut self, session: SessionId) { + pub(crate) fn remove(&mut self, session: SessionId) { let Some(registered) = self.by_session.remove(&session) else { return; }; @@ -347,7 +413,7 @@ impl AuraIndex { .get(&(shard, target_guid)) .map(|rows| { rows.iter() - .filter(|a| a.eff_kind == super::subscriptions::A_STEALTH) + .filter(|a| a.eff_kind == crate::codec::combat::subscriptions::A_STEALTH) .count() }) .unwrap_or(0) @@ -405,7 +471,7 @@ impl WorldView { } /// Record whether `character_guid`'s client now shows an Instance Removal countdown. - fn note_countdown(&self, character_guid: u64, shown: bool) { + pub(crate) fn note_countdown(&self, character_guid: u64, shown: bool) { let mut countdowns = self.countdowns_shown.lock().unwrap(); if shown { countdowns.insert(character_guid); @@ -414,7 +480,7 @@ impl WorldView { } } - fn countdown_shown(&self, character_guid: u64) -> bool { + pub(crate) fn countdown_shown(&self, character_guid: u64) -> bool { self.countdowns_shown .lock() .unwrap() @@ -423,7 +489,7 @@ impl WorldView { /// Record that `guid` reached the `game_character.online` transition — the one signal a /// later Account Claim close checks before it answers FRIEND_OFFLINE. - fn mark_character_online(&self, guid: u64) { + pub(crate) fn mark_character_online(&self, guid: u64) { self.online_characters .lock() .unwrap_or_else(|p| p.into_inner()) @@ -432,14 +498,14 @@ impl WorldView { /// Consume `guid`'s online mark, if this Gateway ever recorded one. `true` only for a /// Character this Gateway itself watched come online. - fn take_character_online(&self, guid: u64) -> bool { + pub(crate) fn take_character_online(&self, guid: u64) -> bool { self.online_characters .lock() .unwrap_or_else(|p| p.into_inner()) .remove(&guid) } - fn viewer(&self, session: SessionId) -> Option> { + pub(crate) fn viewer(&self, session: SessionId) -> Option> { self.viewers .read() .unwrap() @@ -448,7 +514,7 @@ impl WorldView { .map(|registered| registered.viewer.clone()) } - fn session_of_owner(&self, self_guid: u64) -> Option { + pub(crate) fn session_of_owner(&self, self_guid: u64) -> Option { self.viewers .read() .unwrap() @@ -485,7 +551,7 @@ impl WorldView { (registered.shard == shard).then(|| registered.viewer.clone()) } - fn viewer_of_identity_on_shard( + pub(crate) fn viewer_of_identity_on_shard( &self, shard: ShardId, identity: BoundIdentity, @@ -506,7 +572,7 @@ impl WorldView { .map(|registered| registered.viewer.clone()) } - fn viewers_on_shard(&self, shard: ShardId) -> Vec> { + pub(crate) fn viewers_on_shard(&self, shard: ShardId) -> Vec> { let registry = self.viewers.read().unwrap(); registry .sessions_by_shard @@ -532,12 +598,12 @@ impl WorldView { .collect() } - fn shard(&self, id: ShardId) -> Option { + pub(crate) fn shard(&self, id: ShardId) -> Option { self.shards.read().unwrap().get(id).cloned() } /// The Shard `session`'s viewer is registered on. - fn shard_of_viewer(&self, session: SessionId) -> Option { + pub(crate) fn shard_of_viewer(&self, session: SessionId) -> Option { let registry = self.viewers.read().unwrap(); registry .by_session @@ -590,7 +656,12 @@ impl WorldView { /// Geometric recipients plus the row owner's session. The owner lookup lives in the viewer /// registry because it is session identity, not spatial state. - fn world_entity_recipients(&self, shard: ShardId, guid: u64, key: CellKey) -> Vec { + pub(crate) fn world_entity_recipients( + &self, + shard: ShardId, + guid: u64, + key: CellKey, + ) -> Vec { let mut recipients = self.spatial.viewers_of(EntityLayer::WorldEntity, key); if let Some(owner) = self.session_of_owner(guid) { if !recipients.contains(&owner) { @@ -616,7 +687,7 @@ impl WorldView { /// /// The per-viewer gate the job runs is the final filter, unchanged; this only stops the pump /// enqueueing a job for every viewer on the shard so it can reject the row later. - fn cell_audience( + pub(crate) fn cell_audience( &self, shard: ShardId, key: Option, @@ -657,612 +728,11 @@ impl WorldView { pub(crate) fn arm_shard(view: Arc, coord: Coordinator, shard: ShardId) -> Result<()> { let owner = coord.clone(); coord.0.on_pump(move |conn| { - register_shard_callbacks(view.clone(), owner, shard, &conn.db); + crate::stdb::relays::register_shard(view.clone(), owner, shard, &conn.db); seed_shard_from_cache(&view, shard, &conn.db); }) } -#[allow(clippy::too_many_lines)] // One callback registration per relayed shard table. -fn register_shard_callbacks( - view: Arc, - coord: Coordinator, - shard: ShardId, - db: &RemoteTables, -) { - // ---- owner-scoped events --------------------------------------------------------------- - // These callbacks address one viewer directly and survive coordinator reconnects because - // `arm_shard` is re-run on the replacement connection. Every table here holds SHARD rows, - // so each owner lookup is scoped to this shard: a late delta from a transfer's source shard - // (its cascade deletes may still sit in the pump) must never route to the viewer already - // registered on the destination shard. Event-flavored legs (teleport, faction announce, - // item toast) additionally drop the rows a fresh connection's initial apply replays — - // see `wire_insert_live`. - wire_insert_live( - db.game_teleport_event(), - "game_teleport_event.insert", - &view, - move |v, row| teleport_appeared(v, shard, row), - ); - wire_insert_live( - db.game_addon_message(), - "game_addon_message.insert", - &view, - move |v, row| addon_message_appeared(v, shard, row), - ); - wire_insert_live( - db.game_xp_event(), - "game_xp_event.insert", - &view, - move |v, row| xp_appeared(v, shard, row), - ); - wire_insert_live( - db.game_levelup_event(), - "game_levelup_event.insert", - &view, - move |v, row| levelup_appeared(v, shard, row), - ); - let quest_insert_coord = coord.clone(); - wire_insert( - db.game_character_quest(), - "game_character_quest.insert", - &view, - move |v, row| quest_inserted(v, &quest_insert_coord, shard, row), - ); - let quest_update_coord = coord.clone(); - wire_update( - db.game_character_quest(), - "game_character_quest.update", - &view, - move |v, old, row| quest_updated(v, &quest_update_coord, shard, old, row), - ); - let quest_delete_coord = coord.clone(); - wire_delete( - db.game_character_quest(), - "game_character_quest.delete", - &view, - move |v, row| quest_deleted(v, &quest_delete_coord, shard, row), - ); - { - let coord = coord.clone(); - wire_insert( - db.game_character_explored(), - "game_character_explored.insert", - &view, - move |v, row| exploration_appeared(v, &coord, shard, row), - ); - } - wire_insert_live( - db.game_player_reputation(), - "game_player_reputation.insert", - &view, - move |v, row| reputation_appeared(v, shard, row), - ); - wire_update( - db.game_player_reputation(), - "game_player_reputation.update", - &view, - move |v, _old, row| reputation_appeared(v, shard, row), - ); - wire_insert( - db.game_hunter_pet_protocol(), - "game_hunter_pet_protocol.insert", - &view, - move |v, row| hunter_pet_appeared(v, shard, row), - ); - wire_update( - db.game_hunter_pet_protocol(), - "game_hunter_pet_protocol.update", - &view, - move |v, old, row| { - if old != row { - hunter_pet_appeared(v, shard, row) - } - }, - ); - { - let coord = coord.clone(); - wire_insert_live( - db.game_item_instance(), - "game_item_instance.insert", - &view, - move |v, row| item_inserted(v, &coord, shard, row), - ); - } - { - let coord = coord.clone(); - wire_update( - db.game_item_instance(), - "game_item_instance.update", - &view, - move |v, old, row| item_updated(v, &coord, shard, old, row), - ); - } - { - let coord = coord.clone(); - wire_delete( - db.game_item_instance(), - "game_item_instance.delete", - &view, - move |v, row| item_deleted(v, &coord, shard, row), - ); - } - - // ---- game_world_entity ----------------------------------------------------------------- - wire_insert( - db.game_world_entity(), - "game_world_entity.insert", - &view, - move |v, row| entity_appeared(v, shard, row), - ); - { - // The update leg carries the live zone as well as the position: the Module re-stamps - // `zone_id` on the same grid-crossing that moves the row, so a viewer's own row is the - // gateway's only zone signal (`CMSG_ZONEUPDATE` is deliberately unhandled). - let coord = coord.clone(); - wire_update( - db.game_world_entity(), - "game_world_entity.update", - &view, - move |v, old, new| { - entity_changed(v, shard, old, new); - zone_crossed(v, &coord, shard, new.guid, old.zone_id, new.zone_id); - }, - ); - } - wire_delete( - db.game_world_entity(), - "game_world_entity.delete", - &view, - move |v, row| entity_vanished(v, shard, row.guid, row.owner_guid), - ); - - // ---- game_encounter_equip --------------------------------------------------------------- - // Creature virtual-item displays. The durable row feeds a late observer's peer CREATE path; - // these callbacks keep already-visible creatures live without re-creating the object. - wire_insert( - db.game_encounter_equip(), - "game_encounter_equip.insert", - &view, - move |v, row| encounter_equip_changed(v, shard, row, false), - ); - wire_update( - db.game_encounter_equip(), - "game_encounter_equip.update", - &view, - move |v, _old, row| encounter_equip_changed(v, shard, row, false), - ); - wire_delete( - db.game_encounter_equip(), - "game_encounter_equip.delete", - &view, - move |v, row| encounter_equip_changed(v, shard, row, true), - ); - - // ---- game_gameobject ------------------------------------------------------------------- - wire_insert( - db.game_gameobject(), - "game_gameobject.insert", - &view, - move |v, row| gameobject_appeared(v, shard, row), - ); - wire_update( - db.game_gameobject(), - "game_gameobject.update", - &view, - move |v, _old, row| gameobject_appeared(v, shard, row), - ); - wire_delete( - db.game_gameobject(), - "game_gameobject.delete", - &view, - move |v, row| gameobject_vanished(v, shard, row.guid), - ); - - // ---- game_entity_motion (peer movement) ------------------------------------------------ - { - let coord = coord.clone(); - wire_insert( - db.game_entity_motion(), - "game_entity_motion.insert", - &view, - move |v, row| motion(v, shard, row, &coord.0.motion_batch.delivery), - ); - } - { - let coord = coord.clone(); - wire_update( - db.game_entity_motion(), - "game_entity_motion.update", - &view, - move |v, _old, row| motion(v, shard, row, &coord.0.motion_batch.delivery), - ); - } - - // ---- game_creature_spline (creature legs) ---------------------------------------------- - wire_insert( - db.game_creature_spline(), - "game_creature_spline.insert", - &view, - move |v, row| creature_leg(v, shard, row), - ); - wire_update( - db.game_creature_spline(), - "game_creature_spline.update", - &view, - move |v, _old, row| creature_leg(v, shard, row), - ); - - // ---- game_taxi_passenger_spline (server-owned player routes) --------------------------- - wire_insert( - db.game_taxi_passenger_spline(), - "game_taxi_passenger_spline.insert", - &view, - move |v, row| taxi_spline(v, shard, row), - ); - wire_update( - db.game_taxi_passenger_spline(), - "game_taxi_passenger_spline.update", - &view, - move |v, _old, row| taxi_spline(v, shard, row), - ); - - // ---- game_rest_state_event -------------------------------------------------------------- - // Rest-state flips (zzz + blue XP bar). Self-only relay. - wire_insert( - db.game_rest_state_event(), - "game_rest_state_event.insert", - &view, - move |v, row| rest_state_appeared(v, shard, row), - ); - - // ---- game_breath_relay_event ------------------------------------------------------------ - // Breath start/stop edges + server-resolved drowning combat log. Self-only relay. - wire_insert( - db.game_breath_relay_event(), - "game_breath_relay_event.insert", - &view, - move |v, row| breath_relay_appeared(v, shard, row), - ); - - // ---- game_instance_removal -------------------------------------------------------------- - // The Instance Removal countdown. Self-only relay: an insert shows the owner the time left, a - // delete hides it. The row is state, so a replayed insert shows the time left again. - wire_insert( - db.game_instance_removal(), - "game_instance_removal.insert", - &view, - move |v, row| instance_removal_started(v, shard, row), - ); - wire_delete( - db.game_instance_removal(), - "game_instance_removal.delete", - &view, - move |v, row| instance_removal_ended(v, shard, row), - ); - - // ---- game_dynamic_object ----------------------------------------------------------------- - // Ground-area spell visuals. Shard broadcast, instance-gated per viewer on insert; the - // delete leg is ungated, matching the per-player relay byte for byte. - wire_insert( - db.game_dynamic_object(), - "game_dynamic_object.insert", - &view, - move |v, row| dynobj_appeared(v, shard, row), - ); - wire_delete( - db.game_dynamic_object(), - "game_dynamic_object.delete", - &view, - move |v, row| dynobj_vanished(v, shard, row), - ); - - // ---- game_corpse -------------------------------------------------------------------------- - // Player corpses. Shard broadcast, instance-gated per viewer on insert/update (the - // body→bones re-CREATE); the delete leg is ungated, matching the per-player relay. - wire_insert( - db.game_corpse(), - "game_corpse.insert", - &view, - move |v, row| corpse_appeared(v, shard, row), - ); - wire_update( - db.game_corpse(), - "game_corpse.update", - &view, - move |v, _old, row| corpse_changed(v, shard, row), - ); - wire_delete( - db.game_corpse(), - "game_corpse.delete", - &view, - move |v, row| corpse_vanished(v, shard, row), - ); - - // ---- game_combat_event -------------------------------------------------------------------- - // Melee/ranged swing log, anchored on the attacker's cell; the created-set visibility gate - // runs inside the job (`combat_event_outbound`), per viewer. - wire_insert( - db.game_combat_event(), - "game_combat_event.insert", - &view, - move |v, row| combat_event_appeared(v, shard, row), - ); - - // ---- game_melee_attack -------------------------------------------------------------------- - // Combat stance (ATTACKSTART/STOP + the owner-only ranged CANCEL_AUTO_REPEAT), anchored on - // the attacker's indexed cell. - wire_insert( - db.game_melee_attack(), - "game_melee_attack.insert", - &view, - move |v, row| melee_engaged(v, shard, row), - ); - wire_delete( - db.game_melee_attack(), - "game_melee_attack.delete", - &view, - move |v, row| melee_disengaged(v, shard, row), - ); - - wire_insert_live( - db.game_resurrect_request(), - "game_resurrect_request.insert", - &view, - move |v, row| resurrect_offered(v, shard, row), - ); - // The Self-Resurrection Option is state, not an event: a replayed row re-sends the same field. - wire_insert( - db.game_self_resurrect_option(), - "game_self_resurrect_option.insert", - &view, - move |v, row| self_res_option_changed(v, shard, row.character_guid, row.spell_id), - ); - wire_update( - db.game_self_resurrect_option(), - "game_self_resurrect_option.update", - &view, - move |v, _old, row| self_res_option_changed(v, shard, row.character_guid, row.spell_id), - ); - wire_delete( - db.game_self_resurrect_option(), - "game_self_resurrect_option.delete", - &view, - move |v, row| self_res_option_changed(v, shard, row.character_guid, 0), - ); - // Guild rows exist only on Realm-core. On a sharded realm this Shard holds none, and - // `arm_realm_private` relays them from the Realm-core connection instead. - wire_guild_relays( - db, - &view, - &coord, - [ - "game_guild_event.insert", - "game_guild_member.insert", - "game_guild_member.update", - "game_guild_member.delete", - "game_guild_petition.insert", - ], - ); - wire_insert_live( - db.game_auction_notice(), - "game_auction_notice.insert", - &view, - |v, row| auction_notice_appeared(v, row), - ); - wire_insert_live( - db.game_system_message_event(), - "game_system_message_event.insert", - &view, - |v, row| system_message_appeared(v, row), - ); - // Only Realm-core writes Realm Chat Lines and Channel Notices. These registrations hear them - // on a Realm whose Realm-core is this database; `arm_realm_private` hears them everywhere else. - wire_insert_live( - db.game_realm_chat_event(), - "game_realm_chat_event.insert", - &view, - |v, row| realm_chat_appeared(v, row), - ); - wire_insert_live( - db.game_chat_channel_notice_event(), - "game_chat_channel_notice_event.insert", - &view, - |v, row| channel_notice_appeared(v, row), - ); - // Each viewer's ignore set follows its own contact rows. Replayed inserts are idempotent. - wire_insert( - db.game_character_contact(), - "game_character_contact.insert", - &view, - move |v, row| contact_changed(v, shard, row, true), - ); - wire_delete( - db.game_character_contact(), - "game_character_contact.delete", - &view, - move |v, row| contact_changed(v, shard, row, false), - ); - // The friend offline notice — a claim CLOSE only, which is always an UPDATE (the account's - // first-ever claim is an insert, and an insert can never be a close). Only Realm-core writes - // `game_account_claim`; this registration hears it on a Realm whose Realm-core is this - // database, `arm_realm_private` everywhere else — the same twin rule as the chat Relay above. - { - let update_coord = coord.clone(); - wire_update( - db.game_account_claim(), - "game_account_claim.update", - &view, - move |v, old, new| account_claim_changed(v, &update_coord, Some(old), new), - ); - } - // The friend ONLINE notice — see `character_online_changed`'s doc comment for why it reads - // `game_character`'s own `online` flag instead of the Account Claim that opens before it. - wire_update( - db.game_character(), - "game_character.update", - &view, - move |v, old, new| { - character_home_changed(v, shard, old, new); - character_online_changed(v, old, new); - }, - ); - wire_insert_live( - db.game_mail_arrival(), - "game_mail_arrival.insert", - &view, - |v, row| mail_arrived(v, row), - ); - wire_group_events( - db, - "game_group_event.insert", - &view, - coord.clone(), - GroupEventSource::Shard(shard), - ); - { - let insert_coord = coord.clone(); - wire_insert( - db.game_group_member(), - "game_group_member.insert", - &view, - move |v, row| group_member_mirror_changed(v, &insert_coord, shard, row), - ); - let delete_coord = coord.clone(); - wire_delete( - db.game_group_member(), - "game_group_member.delete", - &view, - move |v, row| group_member_mirror_changed(v, &delete_coord, shard, row), - ); - } - wire_insert_live( - db.game_trade_event(), - "game_trade_event.insert", - &view, - move |v, row| trade_event_appeared(v, shard, row), - ); - { - let coord = coord.clone(); - wire_insert_live( - db.game_duel_event(), - "game_duel_event.insert", - &view, - move |v, row| duel_event_appeared(v, &coord, shard, row), - ); - } - - // ---- game_aura ------------------------------------------------------------------------------ - // The aura composite: array sync (visibility-gated), self-only duration/run-speed/armor, - // and the stealth HIDE/REVEAL transitions. The pump keeps the aura index current and takes - // the A_STEALTH count from it, row by row in callback order: a Stealth cast's two inserts - // count 1 then 2 (both hide), its two deletes count 1 then 0 (the second reveals). - // Everything else runs in the job. - { - let view = view.clone(); - let coord = coord.clone(); - db.game_aura().on_insert(move |_ctx, row| { - guarded("game_aura.insert", || { - view.auras.upsert(shard, row); - let n = view.auras.stealth_count(shard, row.target_guid); - aura_applied(&view, &coord, shard, row, n) - }); - }); - } - { - let view = view.clone(); - let coord = coord.clone(); - db.game_aura().on_update(move |_ctx, old, row| { - let expires_changed = old.expires_at != row.expires_at; - guarded("game_aura.update", || { - view.auras.upsert(shard, row); - aura_updated(&view, &coord, shard, row, expires_changed) - }); - }); - } - { - let view = view.clone(); - let coord = coord.clone(); - db.game_aura().on_delete(move |_ctx, row| { - guarded("game_aura.delete", || { - view.auras.remove(shard, row); - let n = view.auras.stealth_count(shard, row.target_guid); - aura_removed(&view, &coord, shard, row, n) - }); - }); - } - - // ---- game_spell_cast_event / game_spell_impact_event -------------------------------------- - // Cast visuals (the full cast-lock contract, per viewer) + deferred projectile impact logs, - // both anchored on the caster's cell. - wire_insert( - db.game_spell_cast_event(), - "game_spell_cast_event.insert", - &view, - move |v, row| cast_event_appeared(v, shard, row), - ); - wire_insert( - db.game_spell_impact_event(), - "game_spell_impact_event.insert", - &view, - move |v, row| impact_appeared(v, shard, row), - ); - - // ---- game_emote_event / game_chat_event -------------------------------------------------- - // The social tier. Emotes and say/yell are range-bound, so both are anchored on the sender's - // cell (say/yell keep their yard-range gate inside the job). - { - let coord = coord.clone(); - wire_insert( - db.game_emote_event(), - "game_emote_event.insert", - &view, - move |v, row| emote_appeared(v, &coord, shard, row), - ); - } - { - let coord = coord.clone(); - wire_insert( - db.game_chat_event(), - "game_chat_event.insert", - &view, - move |v, row| chat_appeared(v, &coord, shard, row), - ); - } - - // ---- game_zone_weather ----------------------------------------------------------------------- - // The live sky. ONE callback per shard, routed by the viewer's stored zone — there are no - // per-character weather subscriptions. Insert and update run the same body: the first row a - // zone ever gets is as much a visible change as every later one. - wire_insert( - db.game_zone_weather(), - "game_zone_weather.insert", - &view, - move |v, row| weather_changed(v, shard, row), - ); - wire_update( - db.game_zone_weather(), - "game_zone_weather.update", - &view, - move |v, _old, row| weather_changed(v, shard, row), - ); - - // ---- game_player_skill ---------------------------------------------------------------------- - // Live skill pane. Self-only relay; insert (line learned) and update (skill-up) run the same - // body against the viewer's own slot map. - wire_insert( - db.game_player_skill(), - "game_player_skill.insert", - &view, - move |v, row| skill_changed(v, shard, row), - ); - wire_update( - db.game_player_skill(), - "game_player_skill.update", - &view, - move |v, _old, row| skill_changed(v, shard, row), - ); -} - /// Register the cross-shard private relays on the REALM-CORE connection: group /// events, Realm Chat Lines (whispers included), Channel Notices and Mail Arrivals written /// realm-side for recipients whose home shard is elsewhere. Same recipient-keyed dispatchers as @@ -1275,1655 +745,69 @@ fn register_shard_callbacks( /// /// `coord` is a WORLD handle (the default shard) — `group_event_appeared` needs one for the /// QUEST_SHARE detail JOIN; realm-core's own cache holds no quest catalogue. -/// -/// Re-reads `realm.0.coord()` on every call, so it doubles as the watchdog's post-reconnect -/// re-arm hook, exactly like `arm_shard`. -pub(crate) fn arm_realm_private(view: Arc, realm: Coordinator, coord: Coordinator) { - let guard = realm.0.coord(); - let db = &guard.conn.db; - wire_insert_live( - db.game_auction_notice(), - "realm.game_auction_notice.insert", - &view, - |v, row| auction_notice_appeared(v, row), - ); - wire_group_events( - db, - "realm.game_group_event.insert", - &view, - coord.clone(), - GroupEventSource::RealmCore, - ); - wire_insert_live( - db.game_realm_chat_event(), - "realm.game_realm_chat_event.insert", - &view, - |v, row| realm_chat_appeared(v, row), - ); - wire_insert_live( - db.game_mail_arrival(), - "realm.game_mail_arrival.insert", - &view, - |v, row| mail_arrived(v, row), - ); - wire_insert_live( - db.game_chat_channel_notice_event(), - "realm.game_chat_channel_notice_event.insert", - &view, - |v, row| channel_notice_appeared(v, row), - ); - // The friend offline notice's sharded-realm leg — see `arm_shard`'s twin registration and - // `account_claim_changed`'s doc comment. `game_character` needs no such twin: Realm-core - // holds no Characters, so `character_online_changed` only ever registers in `arm_shard`. - { - let update_coord = coord.clone(); - wire_update( - db.game_account_claim(), - "realm.game_account_claim.update", - &view, - move |v, old, new| account_claim_changed(v, &update_coord, Some(old), new), - ); - } - wire_guild_relays( - db, - &view, - &realm, - [ - "realm.game_guild_event.insert", - "realm.game_guild_member.insert", - "realm.game_guild_member.update", - "realm.game_guild_member.delete", - "realm.game_guild_petition.insert", - ], - ); -} - -/// Reads one Character's `(guild_id, rank_id)` from the Realm-core cache. A closure so relay -/// tests can supply membership without a connection. -pub(crate) type GuildMembershipRead = Arc Option<(u32, u32)> + Send + Sync>; - -fn guild_membership_read(realm: &Coordinator) -> GuildMembershipRead { - let realm = realm.clone(); - Arc::new(move |character_guid| { - realm - .0 - .coord() - .conn - .db - .game_guild_member() - .character_guid() - .find(&character_guid) - .map(|member| (member.guild_id, member.rank_id)) - }) -} - -/// Reads the Guild data one settings-changing Guild Event needs from the Realm-core cache: -/// `TABARD_CHANGED` needs the query response, `ROSTER_TO_ACTOR` needs the roster, `ROSTER_REFRESH` -/// needs both. `guild_event_appeared` calls this once per event and shares the result with every -/// recipient's job. A closure so relay tests can supply a snapshot without a live connection. -pub(crate) type GuildRosterSnapshotRead = - Arc crate::world::GuildEventSnapshot + Send + Sync>; - -fn guild_roster_snapshot_read(realm: &Coordinator) -> GuildRosterSnapshotRead { - let realm = realm.clone(); - Arc::new(move |guild_id, kind| crate::world::GuildEventSnapshot::build(&realm, guild_id, kind)) -} - -/// Reads the open Petition of one Guild Charter from the Realm-core cache. A closure so relay -/// tests can supply a Petition without a connection. -pub(crate) type PetitionRead = Arc Option + Send + Sync>; - -fn petition_read(realm: &Coordinator) -> PetitionRead { - let realm = realm.clone(); - Arc::new(move |charter_item_guid| realm.guild_petition_of_charter(charter_item_guid)) -} - -/// Register the Guild Event relay, the Guild Projection relay and the Guild Charter's Petition id -/// relay on the connection that holds the guild rows. `labels` name the event insert, the member -/// insert, update and delete, and the Petition insert callbacks. -fn wire_guild_relays( - db: &RemoteTables, - view: &Arc, - realm: &Coordinator, - labels: [&'static str; 5], -) { - let [event_insert, member_insert, member_update, member_delete, petition_insert] = labels; - let membership = guild_membership_read(realm); - let snapshot_read = guild_roster_snapshot_read(realm); - let petitions = petition_read(realm); - { - let membership = membership.clone(); - let snapshot_read = snapshot_read.clone(); - wire_insert_live(db.game_guild_event(), event_insert, view, move |v, row| { - if lyracore_shared::guild::event_kind::is_petition(row.kind) { - petition_event_appeared(v, &petitions, row); - } else { - guild_event_appeared(v, &membership, &snapshot_read, row); - } - }); - } - wire_insert(db.game_guild_petition(), petition_insert, view, |v, row| { - charter_petition_opened(v, row) - }); - { - let membership = membership.clone(); - wire_insert( - db.game_guild_member(), - member_insert, - view, - move |v, row| guild_membership_changed(v, &membership, row.character_guid), - ); - } - { - let membership = membership.clone(); - wire_update( - db.game_guild_member(), - member_update, - view, - move |v, _old, row| guild_membership_changed(v, &membership, row.character_guid), - ); - } - wire_delete( - db.game_guild_member(), - member_delete, - view, - move |v, row| guild_membership_changed(v, &membership, row.character_guid), - ); -} - -/// Run one shared-connection callback body with a panic firewall. On a per-player connection a -/// panicking row callback cost one session; here it would cost the shard, so the panic is logged -/// and swallowed. Deliberately not a re-panic and not a `Result`: there is no caller to handle it — -/// the SDK pump is the caller. -fn guarded(what: &str, body: impl FnOnce()) { - if catch_unwind(AssertUnwindSafe(body)).is_err() { - log::error!( - "shared AOI dispatch: a `{what}` callback PANICKED. It was contained (this shard's \ - pump is still running and every other session is unaffected), but the delta it was \ - handling was dropped — a peer may be missing or stale for one or more sessions until \ - its next update." - ); - } -} - -// ----------------------------------------------------------------------------------------------- -// Registration helpers, the clone-per-callback + `guarded(label, ...)` shape that every -// `arm_shard` registration repeated verbatim, factored to one line per callsite. `T::Row` and -// `EventContext` are pinned to the module's generated types, so a mismatched table/body pairing -// is a compile error, not a mislabeled log line. -// ----------------------------------------------------------------------------------------------- - -/// Register an INSERT callback: `wire_insert(db.game_x(), "game_x.insert", &view, |v, row| body(v, row));` -fn wire_insert( - table: T, - label: &'static str, - view: &Arc, - f: impl Fn(&Arc, &T::Row) + Send + 'static, -) where - T: Table, -{ - let view = view.clone(); - table.on_insert(move |_ctx, row| { - guarded(label, || f(&view, row)); - }); -} - -/// True for a row callback fired by a subscription's initial apply rather than a live delta. A -/// coordinator (re)connect replays every resident row as an insert flavored `SubscribeApplied`, -/// racing the watchdog's re-arm — so an armed callback can see the whole table again. -fn is_initial_apply(event: &spacetimedb_sdk::Event) -> bool { - matches!(event, spacetimedb_sdk::Event::SubscribeApplied) -} - -/// Register an INSERT callback for an event-flavored family (toast/announce/teleport): rows -/// replayed by the initial apply are dropped, only live deltas run `f`. State-sync families keep -/// [`wire_insert`] — their replays re-send idempotent state. -fn wire_insert_live( - table: T, - label: &'static str, - view: &Arc, - f: impl Fn(&Arc, &T::Row) + Send + 'static, -) where - T: Table, -{ - let view = view.clone(); - table.on_insert(move |ctx, row| { - if is_initial_apply(&ctx.event) { - return; - } - guarded(label, || f(&view, row)); - }); -} - -/// [`wire_insert_live`] for `game_group_event` from `source`. The dispatcher also gets a read of -/// the cached table, which already holds the whole transaction when the callback runs. One -/// [`GroupEventSnapshot`] per source database runs the read once per transaction, and only when a -/// recipient with a session needs it, because the SDK copies every cached row to iterate. -fn wire_group_events( - db: &RemoteTables, - label: &'static str, - view: &Arc, - coord: Coordinator, - source: GroupEventSource, -) { - let view = view.clone(); - let mut snapshot = GroupEventSnapshot::default(); - db.game_group_event().on_insert(move |ctx, row| { - if is_initial_apply(&ctx.event) { - return; - } - guarded(label, || { - group_event_appeared(&view, &coord, source, row, &mut snapshot, || { - ctx.db.game_group_event().iter().collect() - }); - }); - }); -} - -/// Register an UPDATE callback (see [`wire_insert`]); `f` gets `(view, old, new)`. -fn wire_update( - table: T, - label: &'static str, - view: &Arc, - f: impl Fn(&Arc, &T::Row, &T::Row) + Send + 'static, -) where - T: TableWithPrimaryKey, -{ - let view = view.clone(); - table.on_update(move |_ctx, old, new| { - guarded(label, || f(&view, old, new)); - }); -} - -/// Register a DELETE callback (see [`wire_insert`]). -fn wire_delete( - table: T, - label: &'static str, - view: &Arc, - f: impl Fn(&Arc, &T::Row) + Send + 'static, -) where - T: Table, -{ - let view = view.clone(); - table.on_delete(move |_ctx, row| { - guarded(label, || f(&view, row)); - }); -} - -// =============================================================================================== -// Entity dispatch -// =============================================================================================== - -/// A row's cell, taken from the grid columns the WRITER stamped — not recomputed from `x`/`y`. -/// -/// The subscription predicate this replaces filtered on `grid_x`/`grid_y`, so reading the same -/// columns is what makes the index and the deleted SQL agree by construction rather than by both -/// happening to call `grid_cell` the same way. -fn entity_key(row: &WorldEntity) -> CellKey { - CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y) -} - -/// A row entered the world (or this shard). Index it, then offer a CREATE to everyone who can see -/// its cell. -fn entity_appeared(view: &Arc, shard: ShardId, row: &WorldEntity) { - let key = entity_key(row); - view.spatial.upsert_entity( - EntityLayer::WorldEntity, - row.guid, - key, - shard, - row.owner_guid, - ); - let row = Arc::new(row.clone()); - for session in view.world_entity_recipients(shard, row.guid, key) { - offer_create_job(view, shard, session, &row); - } -} - -/// Create or update for the new cell's viewers, destroy for viewers left behind, -/// and leave all other viewers untouched. -fn entity_changed(view: &Arc, shard: ShardId, old: &WorldEntity, new: &WorldEntity) { - let key = entity_key(new); - let previous = view.spatial.upsert_entity( - EntityLayer::WorldEntity, - new.guid, - key, - shard, - new.owner_guid, - ); - let new_recipients = view.world_entity_recipients(shard, new.guid, key); - if let Some(old_key) = previous { - if old_key != key { - let still: HashSet = new_recipients.iter().copied().collect(); - for viewer in view.cell_audience(shard, Some(old_key), BOX_HALF_SPAN, &[]) { - if !still.contains(&viewer.session) { - destroy_job(view, viewer.session, new.guid, new.owner_guid); - } - } - } - } - let old = Arc::new(old.clone()); - let new = Arc::new(new.clone()); - for session in new_recipients { - update_job(view, shard, session, &old, &new); - } -} - -/// The row is gone from the database. Everyone who could see it gets a DESTROY. -fn entity_vanished(view: &WorldView, shard: ShardId, guid: u64, owner_guid: u64) { - let Some((key, _)) = view - .spatial - .remove_entity(EntityLayer::WorldEntity, guid, shard) - else { - return; - }; - for session in view.world_entity_recipients(shard, guid, key) { - destroy_job(view, session, guid, owner_guid); - } -} - -fn encounter_equip_changed(view: &WorldView, shard: ShardId, row: &EncounterEquip, cleared: bool) { - let Some(coord) = view.shard(shard) else { - return; - }; - let Some(entity) = coord - .0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&row.creature_guid) - else { - return; - }; - let key = entity_key(&entity); - let row = Arc::new(row.clone()); - for session in view.world_entity_recipients(shard, row.creature_guid, key) { - let Some(viewer) = view.viewer(session) else { - continue; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::encounter_equip_outbound(&viewer, &row, cleared) - }); - } -} - -fn offer_create_job( - view: &Arc, - shard: ShardId, - session: SessionId, - row: &Arc, -) { - let Some(viewer) = view.viewer(session) else { - return; - }; - let Some(coord) = view.shard(shard) else { - return; - }; - let (view, row) = (view.clone(), row.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::offer_peer_create_for(&coord, &view, shard, &viewer, &row) - }); -} - -fn update_job( - view: &Arc, - shard: ShardId, - session: SessionId, - old: &Arc, - new: &Arc, -) { - let Some(viewer) = view.viewer(session) else { - return; - }; - let Some(coord) = view.shard(shard) else { - return; - }; - let (view, old, new) = (view.clone(), old.clone(), new.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_entity_update(&coord, &view, shard, &viewer, &old, &new) - }); -} - -fn destroy_job(view: &WorldView, session: SessionId, guid: u64, owner_guid: u64) { - let Some(viewer) = view.viewer(session) else { - return; - }; - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_peer_destroy(&viewer, guid, owner_guid) - }); -} - -// =============================================================================================== -// Gameobject dispatch -// =============================================================================================== - -fn gameobject_appeared(view: &WorldView, shard: ShardId, row: &GameObject) { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - let previous = view - .spatial - .upsert_entity(EntityLayer::GameObject, row.guid, key, shard, 0); - let recipients = view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]); - if let Some(old_key) = previous { - if old_key != key { - let still: HashSet = recipients.iter().map(|v| v.session).collect(); - for viewer in view.cell_audience(shard, Some(old_key), BOX_HALF_SPAN, &[]) { - if !still.contains(&viewer.session) { - let guid = row.guid; - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_gameobject_destroy(&viewer, guid) - }); - } - } - } - } - let Some(coord) = view.shard(shard) else { - return; - }; - let row = Arc::new(row.clone()); - for viewer in recipients { - let (coord, row) = (coord.clone(), row.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_gameobject_create(&coord, &viewer, &row) - }); - } -} - -fn gameobject_vanished(view: &WorldView, shard: ShardId, guid: u64) { - let Some((key, _)) = view - .spatial - .remove_entity(EntityLayer::GameObject, guid, shard) - else { - return; - }; - for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - // DESTROY stays ungated (the `on_melee_delete` precedent): SMSG_DESTROY_OBJECT for a guid - // the client never created is a client no-op, and gating it risks a stale object. - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_gameobject_destroy(&viewer, guid) - }); - } -} - -// =============================================================================================== -// Motion + creature legs — no index of their own: the row carries its own cell. -// =============================================================================================== - -fn motion( - view: &WorldView, - shard: ShardId, - row: &EntityMotion, - delivery: &super::movement_batch::MotionDelivery, -) { - delivery.received(); - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - let row = Arc::new(row.clone()); - // The MOVER is deliberately not a recipient (`world_entity_recipients`' self leg is skipped - // here): a - // player must never receive their own movement echoed back, or they fight the server for - // authority over their position. `relay_entity_motion` re-checks it anyway. - for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - if viewer.self_guid == row.guid { - continue; - } - // SUPERSEDE, don't append — the viewer needs only this mover's latest position, so - // the upsert replaces any not-yet-flushed heartbeat from the same mover and the queue - // carries at most one flush job regardless of crowd size. - viewer - .motion_pending - .entity - .lock() - .unwrap() - .insert(row.guid, row.clone()); - maybe_queue_motion_flush(view, &viewer); - } -} - -fn creature_leg(view: &WorldView, shard: ShardId, row: &CreatureSpline) { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - let row = Arc::new(row.clone()); - for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - // Same supersession as player motion — latest leg per creature wins. - viewer - .motion_pending - .creature - .lock() - .unwrap() - .insert(row.guid, row.clone()); - maybe_queue_motion_flush(view, &viewer); - } -} - -/// Queue this viewer's ONE motion-flush job, unless one is already queued. The egress shed -/// decision keeps its pump-side, one-atomic-load shape (task B2's contract): a shed drops the -/// FLUSH — the pending map keeps superseding meanwhile, the flag resets so a later heartbeat -/// retries, and nothing is stranded. -fn maybe_queue_motion_flush(_view: &WorldView, viewer: &Arc) { - use std::sync::atomic::Ordering as AtOrd; - if viewer.motion_pending.flush_queued.swap(true, AtOrd::AcqRel) { - return; // a queued flush will carry the upsert that just landed - } - if super::subscriptions::shed_motion_at_depth(viewer.tx.depth()) { - super::subscriptions::MOTION_DROPPED.fetch_add(1, Ordering::Relaxed); - viewer - .motion_pending - .flush_queued - .store(false, AtOrd::Release); - return; - } - let viewer = viewer.clone(); - enqueue(viewer.clone(), move |viewer| { - // Clear BEFORE draining: an upsert racing the drain re-queues a follow-up flush (possibly - // empty — harmless) instead of being stranded behind a still-set flag. - viewer - .motion_pending - .flush_queued - .store(false, AtOrd::Release); - let moves: Vec> = { - let mut m = viewer.motion_pending.entity.lock().unwrap(); - m.drain().map(|(_, r)| r).collect() - }; - let legs: Vec> = { - let mut m = viewer.motion_pending.creature.lock().unwrap(); - m.drain().map(|(_, r)| r).collect() - }; - let mut out = Vec::new(); - for row in moves { - out.extend(super::subscriptions::motion_outbound( - &viewer.created, - viewer.self_guid, - &row, - )); - } - for row in legs { - out.extend(super::subscriptions::creature_leg_outbound( - &viewer.created, - &row, - )); - } - out - }); -} - -// =============================================================================================== -// Non-spatial dispatch (shared-call path) -// =============================================================================================== - -/// Every viewer a shard-broadcast family may reach: the sessions whose characters `shard`'s -/// database owns — the same audience the per-player subscriptions produced (each player connection -/// was to its own shard's database, and the base queries were unscoped `SELECT *`s). Filtered by -/// direct shard membership; family-specific gates (instance, range) run per viewer at the call site. -fn viewers_on_shard(view: &WorldView, shard: ShardId) -> Vec> { - view.viewers_on_shard(shard) -} - -/// A ground-area dynamic object appeared (Consecration's swirl etc.) → CREATE for every -/// same-instance viewer on the row's shard — the same `instance_relay_gate` the per-player leg -/// runs. Short-lived, so no login/AOI resident sweep (a mid-area login misses the visual), -/// exactly as before. -fn dynobj_appeared(view: &WorldView, shard: ShardId, row: &DynamicObject) { - let row = Arc::new(row.clone()); - for viewer in viewers_on_shard(view, shard) { - if !super::subscriptions::instance_relay_gate(row.instance_id, Some(viewer.instance_id)) { - continue; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_dynobj_create(&row) - }); - } -} - -/// A dynamic object's area ended → DESTROY to every viewer on the shard. Deliberately NOT -/// instance-gated: the per-player delete leg never was (a DESTROY for a guid the client doesn't -/// know is a no-op), and the A/B contract is byte-for-byte equality. -fn dynobj_vanished(view: &WorldView, shard: ShardId, row: &DynamicObject) { - let guid = row.guid; - for viewer in viewers_on_shard(view, shard) { - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_destroy_object(guid) - }); - } -} - -/// A passenger spline is always sent to its owner. Nearby sessions receive it only after the -/// outbound visibility check confirms that the passenger already exists in their client world. -fn taxi_spline(view: &WorldView, shard: ShardId, row: &TaxiPassengerSpline) { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - let row = row.clone(); - for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[row.character_guid]) { - let row = row.clone(); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::taxi_spline_outbound(&viewer.created, viewer.self_guid, &row) - }); - } -} - -/// A teleport row names one character owner on this shard. Packet construction stays on that -/// viewer's writer. -fn teleport_appeared(view: &WorldView, shard: ShardId, row: &TeleportEvent) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.mover_guid)) else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::teleport_event_outbound(&row) - }); -} - -/// Addon messages are identity-addressed, with the addressed viewer as the wire sender. -fn addon_message_appeared(view: &WorldView, shard: ShardId, row: &AddonMessage) { - let Some(viewer) = - view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) - else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::addon_message_outbound(viewer.self_guid, &row) - }); -} - -/// XP rows are delivered to the one viewer bound to their recipient identity. -fn xp_appeared(view: &WorldView, shard: ShardId, row: &XpEvent) { - let Some(viewer) = - view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) - else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::xp_event_outbound(&row) - }); -} - -/// Level-up rows are delivered to the one viewer bound to their recipient identity. -fn levelup_appeared(view: &WorldView, shard: ShardId, row: &LevelupEvent) { - let Some(viewer) = - view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) - else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::levelup_event_outbound(&row) - }); -} - -/// A quest-log insert names one owner. The full log is read after the delta on that owner's writer. -fn quest_inserted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &CharacterQuest) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (coord, self_guid) = (coord.clone(), viewer.self_guid); - enqueue(viewer.clone(), move |_| { - let guard = coord.0.coord(); - super::subscriptions::quest_log_outbound(&guard.conn.db, self_guid) - .into_iter() - .collect() - }); -} - -/// A quest update keeps kill feedback, timer failure, then the full quest-log sync in one writer job. -fn quest_updated( - view: &WorldView, - coord: &Coordinator, - shard: ShardId, - old: &CharacterQuest, - row: &CharacterQuest, -) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (coord, self_guid, old, row) = (coord.clone(), viewer.self_guid, old.clone(), row.clone()); - enqueue(viewer.clone(), move |_| { - let guard = coord.0.coord(); - super::subscriptions::quest_update_outbound(&guard.conn.db, self_guid, &old, &row) - }); -} - -/// A quest deletion re-sends the full log, including the all-zero final-log clear. -fn quest_deleted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &CharacterQuest) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (coord, self_guid) = (coord.clone(), viewer.self_guid); - enqueue(viewer.clone(), move |_| { - let guard = coord.0.coord(); - super::subscriptions::quest_log_outbound(&guard.conn.db, self_guid) - .into_iter() - .collect() - }); -} - -/// An explored-area row restores the complete fog word first, then reports a new discovery. -/// Cache reads and replay-state mutation stay on the addressed session's writer queue. -fn exploration_appeared( - view: &WorldView, - coord: &Coordinator, - shard: ShardId, - row: &CharacterExplored, -) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (row, coord) = (row.clone(), coord.clone()); - enqueue(viewer.clone(), move |viewer| { - exploration_outbound(&coord, &viewer, &row) - }); -} - -fn exploration_outbound( - coord: &Coordinator, - viewer: &Viewer, - row: &CharacterExplored, -) -> Vec { - if row.area_bit < 0 { - return Vec::new(); - } - let word_idx = (row.area_bit / 32) as u16; - let lo = word_idx as i32 * 32; - let word = { - let guard = coord.0.coord(); - guard - .conn - .db - .game_character_explored() - .iter() - .filter(|candidate| { - candidate.character_guid == viewer.self_guid - && candidate.area_bit >= lo - && candidate.area_bit < lo + 32 - }) - .fold(0u32, |word, candidate| { - word | (1u32 << (candidate.area_bit - lo)) - }) - }; - exploration_outbound_for_word(viewer, row, word_idx, word) -} - -fn exploration_outbound_for_word( - viewer: &Viewer, - row: &CharacterExplored, - word_idx: u16, - word: u32, -) -> Vec { - let (opcode, body) = - crate::codec::build_explored_zones_values(viewer.self_guid, word_idx, word); - let mut outbound = vec![Outbound::Raw { opcode, body }]; - if viewer.explored.lock().unwrap().admit_once(row.area_bit) { - let (opcode, body) = - crate::codec::build_exploration_experience_raw(row.area_id, row.experience); - outbound.push(Outbound::Raw { opcode, body }); - } - outbound -} - -/// A standing row is delivered only to its owner. The wire packet uses the Faction.dbc -/// ReputationListID, never the game's faction id. -fn reputation_appeared(view: &WorldView, shard: ShardId, row: &PlayerReputation) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |_| reputation_outbound(&row)); -} - -/// Hunter pet descriptors are idempotent VALUES, so replay re-sends are state-sync, not toasts. -fn hunter_pet_appeared(view: &WorldView, shard: ShardId, row: &HunterPetProtocol) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { - return; - }; - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::hunter_pet_outbound(&row) - }); -} - -fn reputation_outbound(row: &PlayerReputation) -> Vec { - if row.reputation_index < 0 { - return Vec::new(); - } - crate::codec::build_set_faction_standing_raw(row.reputation_index as u32, row.standing) - .map(|(opcode, body)| vec![Outbound::Raw { opcode, body }]) - .unwrap_or_default() -} - -/// Select one item owner registered on the firing shard; cache reads and packet work run on its -/// writer queue. -fn item_owner_job( - view: &WorldView, - shard: ShardId, - owner_guid: u64, - job: impl FnOnce(Arc) -> Vec + Send + 'static, -) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(owner_guid)) else { - return; - }; - enqueue(viewer, job); -} - -fn item_inserted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &ItemInstance) { - item_visible_changed(view, shard, None, Some(row), item_slot_read(coord)); - let (coord, row) = (coord.clone(), row.clone()); - item_owner_job(view, shard, row.owner_guid, move |viewer| { - let enchantment = item_enchantment_word(&coord, &row); - let guard = coord.0.coord(); - super::subscriptions::item_instance_insert_outbound( - &guard.conn.db, - viewer.self_guid, - &row, - enchantment, - ) - }); -} - -/// The value of ITEM_FIELD_ENCHANTMENT for a newly inserted item. A Guild Charter shows its Petition -/// id, read from the Realm-core cache before the Home Shard's cache guard is taken; it shows 0 when -/// its Petition is not open yet, and the Petition insert relay sends it later. Every other item -/// shows the enchantment ID the client resolves for its permanent enchant. -fn item_enchantment_word(coord: &Coordinator, row: &ItemInstance) -> u32 { - if row.entry != lyracore_shared::guild::GUILD_CHARTER_ENTRY { - return client_enchantment_id(row.enchant_id); - } - coord - .realm_core() - .ok() - .and_then(|realm| realm.charter_petition_id(row.guid)) - .unwrap_or(0) -} - -fn item_updated( - view: &WorldView, - coord: &Coordinator, - shard: ShardId, - old: &ItemInstance, - row: &ItemInstance, -) { - item_visible_changed(view, shard, Some(old), Some(row), item_slot_read(coord)); - let (coord, old, row) = (coord.clone(), old.clone(), row.clone()); - item_owner_job(view, shard, row.owner_guid, move |viewer| { - let guard = coord.0.coord(); - super::subscriptions::item_instance_update_outbound( - &guard.conn.db, - viewer.self_guid, - &old, - &row, - ) - }); -} - -fn item_deleted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &ItemInstance) { - item_visible_changed(view, shard, Some(row), None, item_slot_read(coord)); - let (coord, row) = (coord.clone(), row.clone()); - item_owner_job(view, shard, row.owner_guid, move |viewer| { - let guard = coord.0.coord(); - super::subscriptions::item_instance_delete_outbound(&guard.conn.db, viewer.self_guid, &row) - }); -} - -type ItemSlotRead = Arc Option + Send + Sync>; - -fn item_slot_read(coord: &Coordinator) -> ItemSlotRead { - let coord = coord.clone(); - Arc::new(move |owner_guid, slot| { - coord - .0 - .coord() - .conn - .db - .game_item_instance() - .iter() - .find(|item| item.owner_guid == owner_guid && item.slot == slot) - .map(|item| super::reads::view_of_item_row(&item)) - }) -} - -/// Peers see worn gear only. The first eligible writer reads the current slot occupant; -/// all recipients share it so the item table is scanned once per changed slot. -fn item_visible_changed( - view: &WorldView, - shard: ShardId, - old: Option<&ItemInstance>, - new: Option<&ItemInstance>, - item_in_slot: ItemSlotRead, -) { - let visible = |row: &ItemInstance| { - ( - row.owner_guid, - row.slot, - row.entry, - row.random_property_id, - row.enchant_id, - ) - }; - if old.map(visible) == new.map(visible) { - return; - } - let mut slots = Vec::with_capacity(2); - for row in old.into_iter().chain(new) { - let slot = (row.owner_guid, row.slot); - if row.slot <= 18 && !slots.contains(&slot) { - slots.push(slot); - } - } - for (owner_guid, slot) in slots { - let item = Arc::new(OnceLock::new()); - let key = view - .spatial - .entity_cell_on_shard(EntityLayer::WorldEntity, owner_guid, shard); - for viewer in view.cell_audience(shard, key, BOX_HALF_SPAN, &[]) { - if viewer.self_guid == owner_guid { - continue; - } - let item_in_slot = item_in_slot.clone(); - let item = item.clone(); - enqueue(viewer, move |viewer| { - if !viewer.created.lock().unwrap().contains(&owner_guid) { - return Vec::new(); - } - let item = item.get_or_init(|| item_in_slot(owner_guid, slot).unwrap_or_default()); - crate::codec::build_visible_item_values(owner_guid, slot, item) - .map(|values| { - vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(values), - ))] - }) - .unwrap_or_default() - }); - } - } -} - -/// Send a rest-state change to its owner on the callback's Shard. -fn rest_state_appeared(view: &WorldView, shard: ShardId, row: &RestStateEvent) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (self_guid, bytes) = (row.character_guid, row.player_bytes_2); - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_rest_state(self_guid, bytes) - }); -} - -/// Send breath events to their owner on the callback's Shard. -fn breath_relay_appeared(view: &WorldView, shard: ShardId, row: &BreathRelayEvent) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (guid, kind, remaining, duration, damage) = ( - row.character_guid, - row.kind, - row.time_remaining_ms, - row.duration_ms, - row.damage, - ); - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_breath_event(guid, kind, remaining, duration, damage) - }); -} - -/// Show an Instance Removal countdown to its owner on the callback's Shard. -fn instance_removal_started(view: &Arc, shard: ShardId, row: &InstanceRemoval) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (view, due) = (view.clone(), row.scheduled_at); - enqueue(viewer, move |viewer| { - let now = super::subscriptions::unix_now_micros(); - instance_removal_time_left_ms(&due, now) - .map(|left| show_countdown(&view, viewer.self_guid, left)) - .into_iter() - .collect() - }); -} - -/// Hide an ended Instance Removal countdown, canceled or expired, from its owner on the callback's -/// Shard. -fn instance_removal_ended(view: &Arc, shard: ShardId, row: &InstanceRemoval) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let view = view.clone(); - enqueue(viewer, move |viewer| { - vec![show_countdown(&view, viewer.self_guid, 0)] - }); -} - -/// `SMSG_RAID_GROUP_ONLY` with `timer_ms` left, 0 to hide the countdown, noting what the client -/// now shows. -fn show_countdown(view: &WorldView, character_guid: u64, timer_ms: u32) -> Outbound { - view.note_countdown(character_guid, timer_ms > 0); - Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY( - crate::codec::build_raid_group_only(timer_ms), - )) -} - -/// The timer a world entry sends: the time left of the countdown `due` on the Home Shard, or 0 to -/// hide a countdown the client was last shown when the Home Shard holds none. A Character that -/// left the Instance Pool through the portal reaches its new Home Shard this way (vmangos hides -/// with the same zero timer, vm:Player.cpp:18561-18569). `None` sends nothing. Pure. -fn resident_countdown_ms( - due: Option<&spacetimedb_sdk::ScheduleAt>, - shown: bool, - now_micros: u64, -) -> Option { - match due { - Some(due) => instance_removal_time_left_ms(due, now_micros), - None => shown.then_some(0), - } -} - -/// Milliseconds left before `due` at `now_micros`, rounded up and never more than the full -/// countdown, so a Gateway clock behind the Module's never shows extra time. `None` once due. Pure. -fn instance_removal_time_left_ms( - due: &spacetimedb_sdk::ScheduleAt, - now_micros: u64, -) -> Option { - let spacetimedb_sdk::ScheduleAt::Time(at) = due else { - return None; - }; - let due_micros = u64::try_from(at.to_micros_since_unix_epoch()).ok()?; - let left_micros = due_micros - .checked_sub(now_micros) - .filter(|left| *left > 0)?; - let left_ms = left_micros.div_ceil(1_000); - Some( - u32::try_from(left_ms).map_or(lyracore_shared::instance::INSTANCE_REMOVAL_MS, |left| { - left.min(lyracore_shared::instance::INSTANCE_REMOVAL_MS) - }), - ) -} - -/// The world-entry Instance Removal packet for `character_guid`, read from its Home Shard `coord`: -/// a reconnect inside the countdown shows the time left, and an arrival without one hides a -/// countdown still on screen. See [`resident_countdown_ms`]. -fn resident_instance_removal_outbound( - view: &WorldView, - coord: &Coordinator, - character_guid: u64, -) -> Vec { - let due = coord - .0 - .coord() - .conn - .db - .game_instance_removal() - .character_guid() - .find(&character_guid) - .map(|row| row.scheduled_at); - let now = super::subscriptions::unix_now_micros(); - resident_countdown_ms(due.as_ref(), view.countdown_shown(character_guid), now) - .map(|timer_ms| show_countdown(view, character_guid, timer_ms)) - .into_iter() - .collect() -} - -/// A skill row changed (line learned / skill-up). Self-only family, same owner-session-lookup -/// audience as [`rest_state_appeared`] — the slot allocation runs in the job on the owner's own -/// writer thread, against the viewer's `skill_slots` (the same map the per-player leg uses). -fn skill_changed(view: &WorldView, shard: ShardId, row: &PlayerSkill) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let (line, cur, max) = (row.skill_line, row.current, row.max_rank); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_skill(&viewer, line, cur, max) - }); -} - -/// A player corpse appeared → CREATE for every same-instance viewer on the row's shard, with the -/// owner's reclaim-delay packet riding inside (`corpse_create_outbound`, the same body the -/// per-player relay and the login resident sweep use). -fn corpse_appeared(view: &WorldView, shard: ShardId, row: &Corpse) { - let row = Arc::new(row.clone()); - for viewer in viewers_on_shard(view, shard) { - if !super::subscriptions::instance_relay_gate(row.instance_id, Some(viewer.instance_id)) { - continue; - } - let row = row.clone(); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |_| { - super::subscriptions::corpse_create_outbound(self_guid, &row) - }); - } -} - -/// A corpse's state changed (body → bones decay) → re-emit the CREATE so the client re-renders it, -/// same instance gate as the insert leg (the re-emit must not leak across instances either). -fn corpse_changed(view: &WorldView, shard: ShardId, row: &Corpse) { - let row = Arc::new(row.clone()); - for viewer in viewers_on_shard(view, shard) { - if !super::subscriptions::instance_relay_gate(row.instance_id, Some(viewer.instance_id)) { - continue; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_corpse_update(&row) - }); - } -} - -/// A corpse was reclaimed/decayed → DESTROY to every viewer on the shard. Ungated like the -/// per-player delete leg (a DESTROY for an unknown guid is a client no-op). -fn corpse_vanished(view: &WorldView, shard: ShardId, row: &Corpse) { - let guid = row.guid; - for viewer in viewers_on_shard(view, shard) { - enqueue(viewer.clone(), move |_| { - super::subscriptions::relay_destroy_object(guid) - }); - } -} - -// =============================================================================================== -// Cell-anchored event dispatch. The Module stamps the actor's cell on the event row (or the -// actor's own row is in the index), so the audience is the actor's box rather than the shard; -// each job's per-viewer gate stays the final filter. -// =============================================================================================== - -/// Cells a yard range can reach from an anchor cell: the range in cells, rounded up, plus one -/// because the listener's anchor is its client position while the gate reads its last durable -/// row, and the two can sit one cell apart. -fn cells_within(range_yd: f32) -> i32 { - (range_yd / GRID_CELL_SIZE).ceil() as i32 + 1 -} - -fn combat_audience(view: &WorldView, shard: ShardId, row: &CombatEvent) -> Vec> { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - view.cell_audience( - shard, - Some(key), - BOX_HALF_SPAN, - &[row.attacker_guid, row.target_guid], - ) -} - -/// Queue a ranged launch or melee swing for each viewer. The job checks its created set. -fn combat_event_appeared(view: &WorldView, shard: ShardId, row: &CombatEvent) { - let row = Arc::new(row.clone()); - for viewer in combat_audience(view, shard, &row) { - let row = row.clone(); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::combat_event_outbound(&viewer.created, &row) - }); - } -} - -/// An engagement row names no cell; the attacker's indexed cell anchors it. -fn melee_audience(view: &WorldView, shard: ShardId, row: &MeleeAttack) -> Vec> { - let key = view - .spatial - .entity_cell_on_shard(EntityLayer::WorldEntity, row.attacker_guid, shard); - view.cell_audience( - shard, - key, - BOX_HALF_SPAN, - &[row.attacker_guid, row.target_guid], - ) -} - -/// A melee/ranged engagement began → SMSG_ATTACKSTART per viewer (`melee_engage_outbound` runs -/// the ranged skip + created gate in the job). -fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { - let row = Arc::new(row.clone()); - for viewer in melee_audience(view, shard, &row) { - let row = row.clone(); - let created = viewer.created.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::melee_engage_outbound(&created, &row) - }); - } -} - -/// An engagement row was removed → ATTACKSTOP / the owner-only CANCEL_AUTO_REPEAT -/// (`melee_disengage_outbound`, keyed on each viewer's own guid). -fn melee_disengaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { - let row = Arc::new(row.clone()); - for viewer in melee_audience(view, shard, &row) { - let row = row.clone(); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |_| { - super::subscriptions::melee_disengage_outbound(self_guid, &row) - }); - } -} - -/// A resurrect offer landed → SMSG_RESURRECT_REQUEST to the offer's TARGET and nobody else. -fn resurrect_offered(view: &WorldView, shard: ShardId, row: &ResurrectRequest) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.target_guid)) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.target_guid, viewer.self_guid) { - return; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::resurrect_request_outbound(&row) - }); -} - -/// A Self-Resurrection Option changed → the owner's `PLAYER_SELF_RES_SPELL` (0 clears it), and -/// nobody else's. -fn self_res_option_changed(view: &WorldView, shard: ShardId, character_guid: u64, spell_id: u32) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(character_guid)) else { - return; - }; - if !super::subscriptions::private_recipient_audience(character_guid, viewer.self_guid) { - return; - } - enqueue(viewer.clone(), move |_| { - super::subscriptions::self_res_option_outbound(character_guid, spell_id) - }); -} - -/// A Realm Chat Line landed → one `SMSG_MESSAGECHAT` per recipient with a World Session on this -/// Gateway, whatever Shard it plays on. The Module chose the audience. The only per-listener -/// filter here is the listener's own ignore list, and only for lines the Module marked ignorable. -fn realm_chat_appeared(view: &WorldView, row: &RealmChatEvent) { - let row = Arc::new(row.clone()); - for &recipient in &row.recipients { - let Some(viewer) = view - .session_of_owner(recipient) - .and_then(|session| view.viewer(session)) - else { - continue; - }; - if !super::subscriptions::private_recipient_audience(recipient, viewer.self_guid) { - continue; - } - if row.ignorable && viewer.ignores(row.speaker_guid) { - continue; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::realm_chat_outbound(&row) - }); - } -} - -/// A Channel Notice landed → one `SMSG_CHANNEL_NOTIFY` per recipient with a World Session on this -/// Gateway, whatever Shard it plays on. The Module chose the audience; no ignore list applies. -fn channel_notice_appeared(view: &WorldView, row: &ChatChannelNoticeEvent) { - let row = Arc::new(row.clone()); - for &recipient in &row.recipients { - let Some(viewer) = view - .session_of_owner(recipient) - .and_then(|session| view.viewer(session)) - else { - continue; - }; - if !super::subscriptions::private_recipient_audience(recipient, viewer.self_guid) { - continue; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::channel_notice_outbound(&row) - }); - } -} - -/// A contact row changed on `shard` → the owner's ignore set or friend set, whichever list the row -/// names, if the owner plays on that Shard. -fn contact_changed(view: &WorldView, shard: ShardId, row: &ContactEntry, present: bool) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { - return; - }; - if row.is_ignore { - viewer.set_ignored(row.target_guid, present); - } else { - viewer.set_friend(row.target_guid, present); - } -} - -/// Rebuild the ignore set AND the friend set of every viewer on `shard` from that Shard's contact -/// rows. A resubscribe replays inserts but never the deletes it missed, so the live callbacks alone -/// could keep a removed contact in force until relog. Runs on the Shard's pump, like its contact -/// callbacks. -fn replace_contact_sets(view: &WorldView, shard: ShardId, contacts: &[ContactEntry]) { - let mut ignored: HashMap> = HashMap::new(); - let mut friends: HashMap> = HashMap::new(); - for row in contacts { - let by_owner = if row.is_ignore { - &mut ignored - } else { - &mut friends - }; - by_owner - .entry(row.owner_guid) - .or_default() - .insert(row.target_guid); - } - for viewer in view.viewers_on_shard(shard) { - *viewer.ignored.lock().unwrap_or_else(|p| p.into_inner()) = - ignored.get(&viewer.self_guid).cloned().unwrap_or_default(); - *viewer.friends.lock().unwrap_or_else(|p| p.into_inner()) = - friends.get(&viewer.self_guid).cloned().unwrap_or_default(); - } -} - -/// The World Sessions that could possibly hear a friend online/offline notice about -/// `subject_guid`, on any Shard: every viewer that lists it as a friend, minus its own viewer. -/// Team is checked separately by each caller — one already knows it for free (the just-committed -/// `game_character` row), the other only after a read. -fn friend_notice_candidates(view: &WorldView, subject_guid: u64) -> Vec> { - view.all_viewers() - .into_iter() - .filter(|viewer| viewer.self_guid != subject_guid && viewer.is_friend(subject_guid)) - .collect() -} - -fn character_home_changed(view: &WorldView, shard: ShardId, old: &Character, new: &Character) { - let home = ( - new.home_x, - new.home_y, - new.home_z, - new.home_map, - new.home_zone, - ); - if home - == ( - old.home_x, - old.home_y, - old.home_z, - old.home_map, - old.home_zone, - ) - { - return; - } - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(new.guid)) else { - return; - }; - enqueue(viewer, move |_| { - let mut body = Vec::with_capacity(20); - for coordinate in [home.0, home.1, home.2] { - body.extend_from_slice(&coordinate.to_le_bytes()); - } - body.extend_from_slice(&home.3.to_le_bytes()); - body.extend_from_slice(&home.4.to_le_bytes()); - vec![Outbound::Raw { - opcode: 0x0155, - body, - }] - }); -} - -/// A Character logged in on this Shard → `FRIEND_ONLINE` to every same-team viewer, on any Shard, -/// who lists it as a friend (cm:CharacterHandler.cpp:856, cm:WorldSession.cpp:754, -/// cm:SocialMgr.cpp:263-292). -/// -/// Hooked to `game_character` (shard-local, inside `player_login`'s own transaction), never the -/// realm-wide Account Claim: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` BEFORE -/// `enter_world`/`player_login`, so a Claim-driven edge could fire for a Character that has not -/// entered the world yet, or never will if `enter_world` then refuses — carrying its stale -/// pre-login level and zone either way. This row IS the just-committed login state, so no further -/// read is needed: race/class/level/zone come straight off it, and Away Status is always `None` -/// (login clears it — nobody crosses a loading screen AFK). -/// -/// The edge is landing on a fresh, nonzero `session_start_micros`, not `online` flipping false → -/// true. `player_login` is the only writer that ever stamps `session_start_micros` to a real value; -/// every `persist_entity` call — a real logout AND a same-database transfer's `freeze_live_entity` -/// alike — zeroes it back out, but `freeze_live_entity` passes `set_offline: false` and so never -/// touches `online`. An abandoned, rolled-back transfer therefore leaves a Character `online: true` -/// with no live entity; a plain `old.online` check would never fire for that Character's real next -/// login, because `online` reads true on both sides of it. Marks the Character in this Gateway's -/// own `online_characters` set, the dedup [`account_claim_changed`] reads before it answers -/// FRIEND_OFFLINE for the same Character's later claim close. -fn character_online_changed(view: &WorldView, old: &Character, new: &Character) { - if new.session_start_micros == 0 || old.session_start_micros == new.session_start_micros { - return; - } - debug_assert!( - new.online, - "player_login always sets online alongside session_start_micros" - ); - view.mark_character_online(new.guid); - let team = lyracore_shared::faction::team_for_race(new.race); - let online_fields = crate::codec::FriendOnline { - away: crate::world::presence::AwayStatus::None, - zone_id: new.zone_id, - level: new.level, - class: new.class, - }; - let subject_guid = new.guid; - for viewer in friend_notice_candidates(view, subject_guid) { - if viewer.team != team { - continue; - } - let online_fields = online_fields.clone(); - enqueue(viewer, move |_| { - let (opcode, body) = crate::codec::build_friend_status_raw( - wow_world_base::shared::friend_result_vanilla_tbc::FriendResult::Online, - subject_guid, - Some(online_fields), - ); - vec![Outbound::Raw { opcode, body }] - }); - } +/// +/// Re-reads `realm.0.coord()` on every call, so it doubles as the watchdog's post-reconnect +/// re-arm hook, exactly like `arm_shard`. +pub(crate) fn arm_realm_private(view: Arc, realm: Coordinator, coord: Coordinator) { + let guard = realm.0.coord(); + crate::stdb::relays::register_realm(view, realm.clone(), coord, &guard.conn.db); } -/// Does an Account Claim row's old and new state close the OLD row's Character? `old` is `None` -/// for the account's first-ever claim, never a close. Reopening after a close, and a fresh open, -/// both answer `None` — that edge is [`character_online_changed`]'s, not this file's. -fn claim_closed(old: Option<&AccountClaim>, new: &AccountClaim) -> Option { - let old = old?; - let replaced = old.generation != new.generation || old.character_guid != new.character_guid; - let was_live = !old.closed; - (was_live && (replaced || new.closed)).then_some(old.character_guid) -} +/// Reads one Character's `(guild_id, rank_id)` from the Realm-core cache. A closure so relay +/// tests can supply membership without a connection. +pub(crate) type GuildMembershipRead = Arc Option<(u32, u32)> + Send + Sync>; -/// The departing Character's team for one Account Claim close — read with the local-cache-only -/// [`presence::character_identity_anywhere`], not the health-checked [`presence::of`]: the -/// Character has just stopped being reachable, so a Whereabouts read has nothing left to prove. -/// Computed ONCE per transition, by whichever candidate's job runs first (cached in the caller's -/// `OnceLock` for every other candidate), on that job's own writer thread, NEVER the shared pump: -/// this read can cross the network to another Shard, and the pump is the one thread every World -/// Session on this Shard depends on. `None` when no connected Shard currently knows the Character -/// at all: never guess a team, which could notify the wrong side. -fn claim_close_team(coord: &Coordinator, subject_guid: u64) -> Option { - let identity = crate::world::presence::character_identity_anywhere(coord, subject_guid) - .ok() - .flatten()?; - Some(lyracore_shared::faction::team_for_race(identity.race)) -} +/// Reads the Guild data one settings-changing Guild Event needs from the Realm-core cache: +/// `TABARD_CHANGED` needs the query response, `ROSTER_TO_ACTOR` needs the roster, `ROSTER_REFRESH` +/// needs both. `guild_event_appeared` calls this once per event and shares the result with every +/// recipient's job. A closure so relay tests can supply a snapshot without a live connection. +pub(crate) type GuildRosterSnapshotRead = + Arc crate::world::GuildEventSnapshot + Send + Sync>; -/// An Account Claim closed on Realm-core → `FRIEND_OFFLINE` to every same-team viewer, on any -/// Shard, who lists the Character as a friend (cm:CharacterHandler.cpp:856, -/// cm:WorldSession.cpp:754, cm:SocialMgr.cpp:263-292) — but ONLY for a Character this Gateway -/// itself watched come online ([`WorldView::take_character_online`]). A claim can close without -/// `player_login` ever having run for it: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` -/// BEFORE `enter_world`, so a login that fails after the claim opens releases it on session -/// teardown with no Character having ever entered the world, and this must answer nothing for it. -/// `coord` is the world handle [`claim_close_team`] reads through — never the Realm-core handle, -/// which holds no Characters. -fn account_claim_changed( - view: &WorldView, - coord: &Coordinator, - old: Option<&AccountClaim>, - new: &AccountClaim, -) { - let Some(subject_guid) = claim_closed(old, new) else { - return; - }; - if !view.take_character_online(subject_guid) { - return; - } - let candidates = friend_notice_candidates(view, subject_guid); - if candidates.is_empty() { - return; - } - let team: Arc>> = Arc::new(OnceLock::new()); - for viewer in candidates { - let (coord, team) = (coord.clone(), team.clone()); - let viewer_team = viewer.team; - enqueue(viewer, move |_| { - let Some(subject_team) = *team.get_or_init(|| claim_close_team(&coord, subject_guid)) - else { - return Vec::new(); - }; - if subject_team != viewer_team { - return Vec::new(); - } - let (opcode, body) = crate::codec::build_friend_status_raw( - wow_world_base::shared::friend_result_vanilla_tbc::FriendResult::Offline, - subject_guid, - None, - ); - vec![Outbound::Raw { opcode, body }] - }); - } -} +/// Reads the open Petition of one Guild Charter from the Realm-core cache. A closure so relay +/// tests can supply a Petition without a connection. +pub(crate) type PetitionRead = Arc Option + Send + Sync>; -/// An Auction Notice landed → SMSG_AUCTION_BIDDER_NOTIFICATION or SMSG_AUCTION_OWNER_NOTIFICATION -/// to the row's RECIPIENT and nobody else (same shape as [`group_event_appeared`]). An offline -/// recipient gets the Auction Mail the same transaction wrote and no packet — the vanilla rule. -fn auction_notice_appeared(view: &WorldView, row: &AuctionNotice) { - let Some(session) = view.session_of_owner(row.recipient_guid) else { - return; - }; - let Some(viewer) = view.viewer(session) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { - return; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::auction_notice_outbound(&row) - }); +#[cfg(test)] +fn is_initial_apply(event: &spacetimedb_sdk::Event) -> bool { + matches!(event, spacetimedb_sdk::Event::SubscribeApplied) } -/// Queue a Package System Message for its addressed World Session. -fn system_message_appeared(view: &WorldView, row: &SystemMessageEvent) { - let Some(session) = view.session_of_owner(row.recipient_guid) else { - return; - }; - let Some(viewer) = view.viewer(session) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { - return; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::system_message_event_outbound(&row) - }); -} +// =============================================================================================== +// Entity dispatch +// =============================================================================================== -/// A Mail became visible to its recipient → SMSG_RECEIVED_MAIL to that recipient's World Session on -/// any Shard. An offline recipient gets nothing and learns of the Mail from the unread poll at the -/// next login (cmangos `Player.cpp:3098-3110`, `MailHandler.cpp:712-720`). -fn mail_arrived(view: &WorldView, row: &MailArrival) { - let Some(session) = view.session_of_owner(row.recipient_guid) else { - return; - }; - let Some(viewer) = view.viewer(session) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { - return; - } - enqueue(viewer.clone(), |_| { - super::subscriptions::mail_arrival_outbound() - }); -} +// =============================================================================================== +// Gameobject dispatch +// =============================================================================================== -/// A trade status landed → the `SMSG_TRADE_STATUS` packet to the row's RECIPIENT and nobody -/// else (same shape as [`resurrect_offered`]). -fn trade_event_appeared(view: &WorldView, shard: ShardId, row: &TradeEvent) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { - return; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::trade_event_outbound(&row) - }); -} +// =============================================================================================== +// Motion + creature legs — no index of their own: the row carries its own cell. +// =============================================================================================== -/// A Duel lifecycle edge landed for one participant. Private packets go only to that participant; -/// the initiator-addressed completion row also drives the one nearby winner announcement. -fn duel_event_appeared(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &DuelEvent) { - if let Some(viewer) = view - .viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) - .filter(|viewer| { - super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) - }) - { - let row = row.clone(); - let coord = coord.clone(); - enqueue(viewer.clone(), move |_| { - let template = (row.kind == lyracore_shared::duel::event_kind::REQUESTED) - .then(|| coord.gameobject_template(row.flag_entry).ok().flatten()) - .flatten(); - super::subscriptions::duel_event_outbound(&row, template.as_ref()) - }); - } +// =============================================================================================== +// Non-spatial dispatch (shared-call path) +// =============================================================================================== - // The module writes two participant rows. Use the initiator-addressed copy as the one AOI fan - // trigger, and exclude both private recipients so each client receives one winner packet. - if row.kind != lyracore_shared::duel::event_kind::COMPLETE - || row.recipient_guid != row.initiator_guid - || row.winner_guid == 0 - { - return; - } - let key = CellKey::of_position(row.map_id, row.instance_id, row.flag_x, row.flag_y); - let row = Arc::new(row.clone()); - for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - if !duel_winner_audience(viewer.self_guid, row.initiator_guid, row.challenged_guid) { - continue; - } - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::duel_winner_outbound(&row) - }); - } +/// Every viewer a shard-broadcast family may reach: the sessions whose characters `shard`'s +/// database owns — the same audience the per-player subscriptions produced (each player connection +/// was to its own shard's database, and the base queries were unscoped `SELECT *`s). Filtered by +/// direct shard membership; family-specific gates (instance, range) run per viewer at the call site. +pub(crate) fn viewers_on_shard(view: &WorldView, shard: ShardId) -> Vec> { + view.viewers_on_shard(shard) } -/// A group/loot-roll/quest-share event landed → the kind-decoded packets to the row's RECIPIENT -/// and nobody else (`coord` feeds the QUEST_SHARE detail JOIN inside the job). The recipient gets -/// its rows of one transaction in the order the Module inserted them ([`GroupEventCursor::take`]). -fn group_event_appeared( - view: &WorldView, - coord: &Coordinator, - source: GroupEventSource, - row: &GroupEvent, - snapshot: &mut GroupEventSnapshot, - read_table: impl FnOnce() -> Vec, -) { - let Some(session) = view.session_of_owner(row.recipient_guid) else { - return; - }; - let Some(viewer) = view.viewer(session) else { - return; - }; - if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { - return; - } - let rows = snapshot.take(&viewer.group_events, source, row, read_table); - if rows.is_empty() { - return; - } - let coord = coord.clone(); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |viewer| { - let mut packets = Vec::new(); - for row in &rows { - packets.extend(super::subscriptions::group_event_outbound( - &coord, self_guid, row, - )); - // A party frame sets every member's online flag, so Member Stats start over behind it. - if row.kind == lyracore_shared::group::event_kind::LIST { - viewer.member_stats.forget_all(); - } - } - packets - }); +// =============================================================================================== +// Cell-anchored event dispatch. The Module stamps the actor's cell on the event row (or the +// actor's own row is in the index), so the audience is the actor's box rather than the shard; +// each job's per-viewer gate stays the final filter. +// =============================================================================================== + +/// Cells a yard range can reach from an anchor cell: the range in cells, rounded up, plus one +/// because the listener's anchor is its client position while the gate reads its last durable +/// row, and the two can sit one cell apart. +pub(crate) fn cells_within(range_yd: f32) -> i32 { + (range_yd / GRID_CELL_SIZE).ceil() as i32 + 1 } /// The database a `game_group_event` row came from. Each database numbers its rows itself. @@ -2946,7 +830,7 @@ impl GroupEventCursor { /// recipient from `cached`, which reads the table that already holds the whole transaction, and /// the later callbacks find theirs taken without calling it. Rows of one transaction share /// `created_at`. - fn take( + pub(crate) fn take( &self, source: GroupEventSource, row: &GroupEvent, @@ -2977,7 +861,7 @@ impl GroupEventCursor { /// The `game_group_event` table as one source database last cached it, grouped by recipient guid. /// Every recipient of a transaction takes its rows from the same read. #[derive(Default)] -struct GroupEventSnapshot(HashMap>); +pub(crate) struct GroupEventSnapshot(HashMap>); impl GroupEventSnapshot { /// [`GroupEventCursor::take`] over the recipient's rows of the snapshot. @@ -2985,7 +869,7 @@ impl GroupEventSnapshot { /// `read_table` runs only when the recipient's group lacks `row`, which is the first callback /// of each transaction. The cache already holds the whole transaction by then, so the later /// callbacks reuse the read. A row the table lacks reads it again. - fn take( + pub(crate) fn take( &mut self, cursor: &GroupEventCursor, source: GroupEventSource, @@ -3011,194 +895,9 @@ impl GroupEventSnapshot { } } -/// A shard mirror delta is the point at which viewer-relative Loot Tag flags can be projected from -/// current membership without racing the realm event that requested the mirror write. -fn group_member_mirror_changed( - view: &WorldView, - coord: &Coordinator, - shard: ShardId, - row: &GroupMember, -) { - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { - return; - }; - let coord = coord.clone(); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::loot_tag_flags_after_membership_change(&coord, &viewer) - }); -} - -/// A membership row changed: re-send the member's Guild Projection to the member and to every -/// viewer that holds its entity. The member is always addressed, as `cell_audience` keeps its owner -/// leg, so an entity the cell index has not seen yet cannot hide a removal from the member's own -/// client. The job reads membership when it runs, so after a delete it sends 0 and 0. -fn guild_membership_changed( - view: &WorldView, - membership: &GuildMembershipRead, - character_guid: u64, -) { - let mut sessions = view - .spatial - .shard_of(EntityLayer::WorldEntity, character_guid) - .and_then(|shard| { - view.spatial - .entity_cell_on_shard(EntityLayer::WorldEntity, character_guid, shard) - .map(|key| view.world_entity_recipients(shard, character_guid, key)) - }) - .unwrap_or_default(); - if let Some(owner) = view.session_of_owner(character_guid) { - if !sessions.contains(&owner) { - sessions.push(owner); - } - } - for session in sessions { - let Some(viewer) = view.viewer(session) else { - continue; - }; - let membership = membership.clone(); - enqueue(viewer, move |viewer| { - let projection = membership(character_guid).unwrap_or((0, 0)); - super::subscriptions::guild_values_outbound(&viewer, character_guid, projection) - }); - } -} - -/// A Guild Event landed. An addressed row reaches its recipient; a broadcast row reaches every -/// online member on this Gateway. A member signing on does not hear its own SIGNED_ON: mangos -/// broadcasts it before the Character is in the world (`cm:CharacterHandler.cpp:787`). -/// -/// `TABARD_CHANGED`, `ROSTER_TO_ACTOR` and `ROSTER_REFRESH` also carry a query response, a roster, -/// or both; the first recipient's job reads the Guild (and, for the two roster kinds, its roster) -/// once through `snapshot_read` and every other recipient's job reuses that one snapshot, so a -/// broadcast to a large Guild costs one Realm-core read, not one per online member. -fn guild_event_appeared( - view: &WorldView, - membership: &GuildMembershipRead, - snapshot_read: &GuildRosterSnapshotRead, - row: &GuildEvent, -) { - let audience: Vec> = if row.recipient_guid != 0 { - view.session_of_owner(row.recipient_guid) - .and_then(|session| view.viewer(session)) - .filter(|viewer| { - super::subscriptions::private_recipient_audience( - row.recipient_guid, - viewer.self_guid, - ) - }) - .into_iter() - .collect() - } else { - online_audience(view, membership, row.guild_id) - }; - let row = Arc::new(row.clone()); - let shared_snapshot: Arc> = - Arc::new(OnceLock::new()); - for viewer in audience { - if row.recipient_guid == 0 - && row.kind == lyracore_shared::guild::event_kind::SIGNED_ON - && viewer.self_guid == row.subject_guid - { - continue; - } - let (row, membership, snapshot_read, shared_snapshot) = ( - row.clone(), - membership.clone(), - snapshot_read.clone(), - shared_snapshot.clone(), - ); - enqueue(viewer, move |viewer| { - use lyracore_shared::guild::event_kind; - match row.kind { - event_kind::TABARD_CHANGED => { - let snapshot = - shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); - let mut out = super::subscriptions::guild_event_outbound(&row); - out.extend(snapshot.query_response().map(Outbound::One)); - out - } - event_kind::ROSTER_TO_ACTOR => { - let Some((_, rank_id)) = - membership(viewer.self_guid).filter(|(id, _)| *id == row.guild_id) - else { - return Vec::new(); - }; - let snapshot = - shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); - snapshot - .roster_for_rank(rank_id) - .into_iter() - .map(Outbound::One) - .collect() - } - event_kind::ROSTER_REFRESH => { - let Some((_, rank_id)) = - membership(viewer.self_guid).filter(|(id, _)| *id == row.guild_id) - else { - return Vec::new(); - }; - let snapshot = - shared_snapshot.get_or_init(|| snapshot_read(row.guild_id, row.kind)); - let mut out: Vec = snapshot - .query_response() - .map(Outbound::One) - .into_iter() - .collect(); - out.extend(snapshot.roster_for_rank(rank_id).map(Outbound::One)); - out - } - _ => super::subscriptions::guild_event_outbound(&row), - } - }); - } -} - -/// A petition Guild Event landed: it reaches its one addressed recipient. The two kinds that show -/// a Petition read it when the job runs, once per event. -fn petition_event_appeared(view: &WorldView, petitions: &PetitionRead, row: &GuildEvent) { - let Some(viewer) = view - .session_of_owner(row.recipient_guid) - .and_then(|session| view.viewer(session)) - .filter(|viewer| { - super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) - }) - else { - return; - }; - let (row, petitions) = (row.clone(), petitions.clone()); - enqueue(viewer, move |_viewer| { - use lyracore_shared::guild::event_kind; - let petition = match row.kind { - event_kind::PETITION_OFFERED | event_kind::PETITION_CHANGED => { - petitions(row.other_guid) - } - _ => None, - }; - super::subscriptions::petition_event_outbound(&row, petition.as_ref()) - }); -} - -/// A Petition opened: its owner's Guild Charter shows the Petition id in ITEM_FIELD_ENCHANTMENT, -/// where the client reads it back for its petition queries (`cm:PetitionsHandler.cpp:141`). -fn charter_petition_opened(view: &WorldView, row: &GuildPetition) { - let Some(viewer) = view - .session_of_owner(row.owner_guid) - .and_then(|session| view.viewer(session)) - .filter(|viewer| viewer.self_guid == row.owner_guid) - else { - return; - }; - let (charter_item_guid, petition_id) = (row.charter_item_guid, row.petition_id); - enqueue(viewer, move |_viewer| { - let (opcode, body) = - crate::codec::build_charter_petition_values(charter_item_guid, petition_id); - vec![Outbound::Raw { opcode, body }] - }); -} - /// Every World Session on this Gateway whose Character is a member of `guild_id`. The one /// audience read for broadcast Guild Events. -fn online_audience( +pub(crate) fn online_audience( view: &WorldView, membership: &GuildMembershipRead, guild_id: u32, @@ -3209,273 +908,6 @@ fn online_audience( .collect() } -/// Aura descriptors follow the target's AOI; channel timers also reach the caster. -fn aura_audience(view: &WorldView, shard: ShardId, row: &Aura) -> Vec> { - let key = view - .spatial - .entity_cell_on_shard(EntityLayer::WorldEntity, row.target_guid, shard); - view.cell_audience( - shard, - key, - BOX_HALF_SPAN, - &[row.target_guid, row.caster_guid], - ) -} - -/// An aura appeared → per viewer: array sync + self-only packets + the stealth HIDE transition -/// (`aura_insert_outbound`, all gates inside the job against that viewer's own state). -fn aura_applied( - view: &Arc, - coord: &Coordinator, - shard: ShardId, - row: &Aura, - stealth_count: usize, -) { - let row = Arc::new(row.clone()); - for viewer in aura_audience(view, shard, &row) { - let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::aura_insert_outbound( - &coord, - &view, - shard, - viewer.session, - &viewer.created, - viewer.self_guid, - &row, - stealth_count, - ) - }); - } -} - -/// An aura changed → per viewer: array sync + refresh-only duration + self-only packets. -fn aura_updated( - view: &Arc, - coord: &Coordinator, - shard: ShardId, - row: &Aura, - expires_changed: bool, -) { - let row = Arc::new(row.clone()); - for viewer in aura_audience(view, shard, &row) { - let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::aura_update_outbound( - &coord, - &view, - shard, - &viewer.created, - viewer.self_guid, - &row, - expires_changed, - ) - }); - } -} - -/// An aura expired/was removed → per viewer: array sync + self-only packets + the stealth REVEAL -/// transition. -fn aura_removed( - view: &Arc, - coord: &Coordinator, - shard: ShardId, - row: &Aura, - stealth_count: usize, -) { - let row = Arc::new(row.clone()); - for viewer in aura_audience(view, shard, &row) { - let (row, coord, view) = (row.clone(), coord.clone(), view.clone()); - enqueue(viewer.clone(), move |viewer| { - super::subscriptions::aura_delete_outbound( - &coord, - &view, - shard, - viewer.session, - &viewer.created, - viewer.self_guid, - &row, - stealth_count, - ) - }); - } -} - -fn cast_audience(view: &WorldView, shard: ShardId, row: &SpellCastEvent) -> Vec> { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - view.cell_audience( - shard, - Some(key), - BOX_HALF_SPAN, - &[row.caster_guid, row.target_guid], - ) -} - -/// A cast event landed → the full cast-lock sequence per viewer (`cast_event_outbound`: the -/// caster-private branches key on each viewer's own guid inside the job). -fn cast_event_appeared(view: &WorldView, shard: ShardId, row: &SpellCastEvent) { - let row = Arc::new(row.clone()); - for viewer in cast_audience(view, shard, &row) { - let row = row.clone(); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |_| { - super::subscriptions::cast_event_outbound(self_guid, &row) - }); - } -} - -fn impact_audience(view: &WorldView, shard: ShardId, row: &SpellImpactEvent) -> Vec> { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - view.cell_audience( - shard, - Some(key), - BOX_HALF_SPAN, - &[row.caster_guid, row.target_guid], - ) -} - -/// A projectile impact landed → the floating damage number per viewer that can see the caster. -fn impact_appeared(view: &WorldView, shard: ShardId, row: &SpellImpactEvent) { - let row = Arc::new(row.clone()); - for viewer in impact_audience(view, shard, &row) { - let row = row.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::impact_event_outbound(&row) - }); - } -} - -/// A text emote names no cell of its own; the row carries the actor's cell (stamped at insert -/// time), widened to the 25 yd text-emote range rather than the AOI box — the job's `chat_in_range` -/// gate is the exact filter, this only bounds the candidate set. -fn emote_audience(view: &WorldView, shard: ShardId, row: &EmoteEvent) -> Vec> { - let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); - view.cell_audience( - shard, - Some(key), - cells_within(lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD), - &[row.sender_guid, row.target_guid], - ) -} - -/// A text emote landed → SMSG_TEXT_EMOTE + SMSG_EMOTE per viewer within 25 yd of the sender -/// (`emote_event_outbound`'s `chat_in_range` gate). Name resolve runs in the job against `coord`'s -/// cache. -fn emote_appeared(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &EmoteEvent) { - let row = Arc::new(row.clone()); - for viewer in emote_audience(view, shard, &row) { - let (row, coord) = (row.clone(), coord.clone()); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |_| { - super::subscriptions::emote_event_outbound(&coord, self_guid, &row) - }); - } -} - -/// A chat line names its speaker and nothing else; the speaker's indexed cell anchors it, widened -/// to the kind's yard range (a yell carries 300 yd, four cells past the box). -fn chat_audience(view: &WorldView, shard: ShardId, row: &ChatEvent) -> Vec> { - let key = view - .spatial - .entity_cell_on_shard(EntityLayer::WorldEntity, row.sender_guid, shard); - let span = cells_within(super::subscriptions::chat_range_yd(row.chat_type)); - view.cell_audience(shard, key, span, &[row.sender_guid, row.target_guid]) -} - -/// A say/yell line landed → SMSG_MESSAGECHAT per viewer, `chat_in_range`-gated inside the job -/// (`chat_event_outbound`: speaker echo, SAY/YELL radii, map+instance fence, coordinator-cache -/// endpoint lookups). -fn chat_appeared(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &ChatEvent) { - let row = Arc::new(row.clone()); - for viewer in chat_audience(view, shard, &row) { - let (row, coord) = (row.clone(), coord.clone()); - let self_guid = viewer.self_guid; - enqueue(viewer.clone(), move |_| { - super::subscriptions::chat_event_outbound(&coord, self_guid, &row) - }); - } -} - -/// A zone's sky changed → `SMSG_WEATHER` to every viewer standing in that zone on this shard, and -/// to nobody else. The zone comparison IS the audience: weather is per zone, so an Elwynn row can -/// never reach a Westfall viewer however close the two stand. -/// -/// Smooth, because this is a scheduled drift the client should fade in. The synchronizing sends -/// (world entry, zone entry) are Instant and come from [`zone_crossed`] and the login path. -/// -/// The row carries the whole packet, so no cache read happens in the job — which also means a -/// later row cannot overtake an earlier one's encoding and show a client the wrong sky. -fn weather_changed(view: &WorldView, shard: ShardId, row: &ZoneWeather) { - let zone_id = row.zone_id; - let view_of_row = crate::codec::ZoneWeatherView { - weather_type: row.weather_type, - intensity: row.intensity, - }; - for viewer in viewers_on_shard(view, shard) { - if viewer.zone_id.load(Ordering::Relaxed) != zone_id { - continue; - } - enqueue(viewer.clone(), move |_| { - super::subscriptions::weather_outbound(view_of_row, WeatherChangeType::Smooth) - }); - } -} - -/// The `game_zone_weather` relay leg, reachable from the world-session tests: the encrypted-wire -/// test drives the SAME body [`arm_shard`] wires to the table callback, so a live weather row and a -/// test row take one path. Test-only because a shard's own callback is the sole production caller. -#[cfg(test)] -pub(crate) fn relay_zone_weather(view: &WorldView, shard: ShardId, row: &ZoneWeather) { - weather_changed(view, shard, row); -} - -/// The self entity's own row says it is standing in a different zone than the viewer's routing -/// state remembers → move the routing state and send the destination zone's current weather ONCE, -/// Instant, so the stale source-zone sky is replaced immediately (story 7). -/// -/// This covers walking, a same-map teleport and a taxi landing — every path that changes the zone -/// without rebuilding the session. A cross-map path tears the viewer down and re-runs world entry, -/// which sends its own weather. -/// -/// `store` is this shard's coordinator, taken through the [`crate::world::WeatherStore`] seam -/// because the destination's sky is READ, on the arriving viewer's own writer thread, rather than -/// carried by the row that triggered this. -/// -/// The row-delta comparison comes FIRST because this sits on the pump, under the highest-rate -/// table in the module: every creature heartbeat and every player step that stays inside its zone -/// leaves here without touching the viewer registry. Creatures never carry a zone at all, so their -/// updates are all `0 -> 0`. -fn zone_crossed( - view: &WorldView, - store: &St, - shard: ShardId, - guid: u64, - old_zone: u32, - zone_id: u32, -) where - St: crate::world::WeatherStore + Clone + 'static, -{ - if zone_id == old_zone { - return; - } - let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(guid)) else { - return; - }; - if !viewer.enter_zone(zone_id) { - return; - } - let store = store.clone(); - enqueue(viewer.clone(), move |_| { - super::subscriptions::zone_weather_outbound(&store, zone_id, WeatherChangeType::Instant) - }); -} - -/// The AOI bystander leg of the winner announcement: both participants get their own copy, so the -/// fan-out must skip them (and the unregistered zero guid). -fn duel_winner_audience(viewer_guid: u64, initiator_guid: u64, challenged_guid: u64) -> bool { - viewer_guid != 0 && viewer_guid != initiator_guid && viewer_guid != challenged_guid -} - // The relay-order tests below scan this file's own source, so the production items that follow // them must stay where they are. #[allow(clippy::items_after_test_module)] @@ -3512,13 +944,15 @@ mod family_audience_tests { GuildMembershipRead, GuildRosterSnapshotRead, MotionPending, OwnerGuid, PetitionRead, Viewer, WorldView, }; + #[cfg(test)] + use crate::codec::quest::subscriptions::quest_update_packets; + use crate::codec::social::subscriptions::private_recipient_audience; use crate::stdb::aoi::ViewerGates; use crate::stdb::bindings::{ AddonMessage, CharacterExplored, CharacterQuest, GuildEvent, GuildPetition, InstanceRemoval, ItemInstance, LevelupEvent, MailArrival, PlayerReputation, SystemMessageEvent, TeleportEvent, XpEvent, ZoneWeather, }; - use crate::stdb::subscriptions::{private_recipient_audience, quest_update_packets}; use crate::stdb::world_index::{CellKey, EntityLayer}; use crate::world::GuildEventSnapshot; use crate::world::{Outbound, SessionTx}; @@ -3533,7 +967,7 @@ mod family_audience_tests { spacetimedb_sdk::Identity::from_byte_array([byte; 32]) } - fn viewer(session: u64, self_guid: u64) -> Arc { + pub(crate) fn viewer(session: u64, self_guid: u64) -> Arc { let (tx, _rx) = SessionTx::with_depth(0); viewer_with_tx(session, self_guid, identity(session as u8), tx) } @@ -6418,7 +3852,7 @@ mod group_event_order_tests { /// Queue work for one World Session. The socket writer checks its lifetime before running it. /// Jobs already running finish before the destination entry batch on the same FIFO writer; /// jobs selected by an old callback but enqueued after that batch are discarded here. -pub(super) fn enqueue( +pub(crate) fn enqueue( viewer: Arc, job: impl FnOnce(Arc) -> Vec + Send + 'static, ) { @@ -6460,7 +3894,7 @@ pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, .unwrap_or(0); let viewer = viewer.clone(); enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_peer_destroy(&viewer, guid, owner_guid) + crate::codec::entity::subscriptions::relay_peer_destroy(&viewer, guid, owner_guid) }); } for (guid, shard) in delta.world_entities.entered { @@ -6480,7 +3914,9 @@ pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, }; let (view, viewer, row) = (view.clone(), viewer.clone(), Arc::new(row)); enqueue(viewer.clone(), move |viewer| { - super::subscriptions::offer_peer_create_for(&coord, &view, shard, &viewer, &row) + crate::codec::entity::subscriptions::offer_peer_create_for( + &coord, &view, shard, &viewer, &row, + ) }); } } @@ -6488,7 +3924,7 @@ pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, for (guid, _) in delta.game_objects.left { let viewer = viewer.clone(); enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_gameobject_destroy(&viewer, guid) + crate::codec::gameobject::subscriptions::relay_gameobject_destroy(&viewer, guid) }); } for (guid, shard) in delta.game_objects.entered { @@ -6500,7 +3936,9 @@ pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, }; let (viewer, row) = (viewer.clone(), Arc::new(row)); enqueue(viewer.clone(), move |viewer| { - super::subscriptions::relay_gameobject_create(&coord, &viewer, &row) + crate::codec::gameobject::subscriptions::relay_gameobject_create( + &coord, &viewer, &row, + ) }); } } @@ -6531,7 +3969,7 @@ pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { else { continue; }; - out.extend(super::subscriptions::offer_peer_create_for( + out.extend(crate::codec::entity::subscriptions::offer_peer_create_for( &coord, &view, shard, &viewer, &row, )); } @@ -6545,19 +3983,23 @@ pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { let Some(row) = coord.0.coord().conn.db.game_gameobject().guid().find(&guid) else { continue; }; - out.extend(super::subscriptions::relay_gameobject_create( - &coord, &viewer, &row, - )); + out.extend( + crate::codec::gameobject::subscriptions::relay_gameobject_create( + &coord, &viewer, &row, + ), + ); } // The login sequence queued self CREATE before registration, so the resident flight // follows it on this writer too. for coord in view.shards.read().unwrap().iter() { - out.extend(super::subscriptions::resident_taxi_spline_outbound( - coord, - &viewer, - viewer.self_guid, - )); + out.extend( + crate::codec::taxi::subscriptions::resident_taxi_spline_outbound( + coord, + &viewer, + viewer.self_guid, + ), + ); } // Only the Home Shard can hold this Character's countdown. Another Shard's row would be a // Transfer source's delete still on its way. @@ -6627,10 +4069,14 @@ fn reconcile_shard( } enqueue(viewer.clone(), move |viewer| match layer { EntityLayer::WorldEntity => { - super::subscriptions::relay_peer_destroy(&viewer, guid, owner_guid) + crate::codec::entity::subscriptions::relay_peer_destroy( + &viewer, guid, owner_guid, + ) } EntityLayer::GameObject => { - super::subscriptions::relay_gameobject_destroy(&viewer, guid) + crate::codec::gameobject::subscriptions::relay_gameobject_destroy( + &viewer, guid, + ) } }); } @@ -6645,7 +4091,7 @@ fn reconcile_shard( for viewer in view.cell_audience(shard, key, BOX_HALF_SPAN, &[target_guid]) { let (coord, view, previous) = (coord.clone(), view.clone(), previous.clone()); enqueue(viewer.clone(), move |viewer| { - super::subscriptions::aura_snapshot_outbound( + crate::codec::combat::subscriptions::aura_snapshot_outbound( &coord, &view, shard, diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index a8bc5e2f8..783c92c53 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1,11 +1,13 @@ //! The cell-anchored relays against the whole-shard-then-gate path they replaced. use super::*; +use crate::codec::combat::subscriptions::A_STEALTH; use crate::codec::property_tests::Rng; +use crate::codec::social::subscriptions::chat_in_range; +use crate::codec::social::subscriptions::chat_range_yd; use crate::stdb::bindings::{ Aura, ChatEvent, CombatEvent, EmoteEvent, MeleeAttack, SpellCastEvent, SpellImpactEvent, }; -use crate::stdb::subscriptions::{chat_in_range, chat_range_yd, A_STEALTH}; use crate::world::{Outbound, SessionTx}; use std::sync::mpsc::Receiver; @@ -937,7 +939,7 @@ fn jobs_selected_before_worldport_cannot_reach_the_replacement_world_session() { ); let delayed_row = row.clone(); enqueue(selected, move |viewer| { - super::super::subscriptions::melee_engage_outbound(&viewer.created, &delayed_row) + crate::codec::combat::subscriptions::melee_engage_outbound(&viewer.created, &delayed_row) }); melee_engaged(&view, 0, &row); diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index f8d347770..c4a80e139 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -158,7 +158,7 @@ fn build(players: usize, rng: &mut Rng, workload: Workload) -> SyntheticRealm { creatures[pick - players].0 }; let kind = if i % 20 == 0 { - super::super::subscriptions::A_STEALTH + crate::codec::combat::subscriptions::A_STEALTH } else { 0xA0 }; @@ -335,7 +335,7 @@ fn aura_burst(realm: &SyntheticRealm, rng: &mut Rng) -> (Duration, usize, Durati continue; } let started = Instant::now(); - let _ = super::super::subscriptions::aura_sync( + let _ = crate::codec::combat::subscriptions::aura_sync( realm.view.auras.on_target(0, row.target_guid).into_iter(), row.target_guid, ); diff --git a/gateway/src/world/handlers/cast/ranged.rs b/gateway/src/world/handlers/cast/ranged.rs index 515a9e61d..808c6ee13 100644 --- a/gateway/src/world/handlers/cast/ranged.rs +++ b/gateway/src/world/handlers/cast/ranged.rs @@ -144,22 +144,19 @@ fn ammo_display(store: &St, self_guid: u64) -> Option<(u if launcher.class != 2 || !matches!(launcher.subclass, 2 | 3 | 18) { return None; } - // Lowest slot among live stacks mirrors the module's per-shot `find_ammo` pick, so the - // projectile nocked on the START is the one each shot consumes. The client's item cache is - // unsorted, and a spent stack must never be nocked. - items - .iter() - .filter(|i| i.stack_count > 0) - .filter(|i| { + lyracore_shared::item::loaded_ammo(items.iter(), |item| { + ( + item.slot, + item.stack_count, store - .item_template(i.entry) + .item_template(item.entry) .ok() .flatten() - .is_some_and(|t| t.class == 6) - }) - .min_by_key(|i| i.slot) - .and_then(|i| store.item_template(i.entry).ok().flatten()) - .map(|t| (t.display_id, 24)) + .map(|tmpl| tmpl.class), + ) + }) + .and_then(|item| store.item_template(item.entry).ok().flatten()) + .map(|tmpl| (tmpl.display_id, 24)) } #[cfg(test)] @@ -473,4 +470,11 @@ mod tests { "slot 2 is spent, so slot 4 is the deterministic pick" ); } + #[test] + fn banked_projectiles_never_supply_the_activation() { + let store = armed_with(launcher(100, 2), vec![(39, 200, 40), (120, 300, 20)]); + assert_eq!(activate_ammo(&store), Some((3000, 24))); + let bank_only = armed_with(launcher(100, 2), vec![(39, 200, 40)]); + assert_eq!(activate_ammo(&bank_only), None); + } } diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 260a165a0..238bf1890 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -3184,7 +3184,8 @@ fn the_realm_core_list_relay_renders_a_member_on_another_shard_online() { recipient_guid: GINGER, }; - let packets = crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + let packets = + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); let mut packets = packets.into_iter(); let (Some(packet), None) = (packets.next(), packets.next()) else { @@ -3576,7 +3577,7 @@ fn relayed_leader_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { - let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); + let packets = crate::codec::social::subscriptions::group_event_outbound(realm, GINGER, row); match &packets[..] { [] => None, [Outbound::One(ServerOpcodeMessage::SMSG_GROUP_SET_LEADER(announced))] => { @@ -3624,7 +3625,7 @@ fn relayed_invite_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { - let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); + let packets = crate::codec::social::subscriptions::group_event_outbound(realm, GINGER, row); match &packets[..] { [] => None, [Outbound::One(ServerOpcodeMessage::SMSG_GROUP_INVITE(invite))] => { @@ -3679,7 +3680,7 @@ fn relayed_decline_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { - let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); + let packets = crate::codec::social::subscriptions::group_event_outbound(realm, GINGER, row); match &packets[..] { [] => None, [Outbound::One(ServerOpcodeMessage::SMSG_GROUP_DECLINE(decline))] => { @@ -4803,7 +4804,7 @@ fn the_relay_renders_each_group_broadcast_kind() { let row = group_event(kind, VIM, payload); let packets = - crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); let sent: Vec<_> = packets.iter().map(wire).collect(); assert_eq!(sent, [expected], "kind {kind} payload {payload:?}"); @@ -4837,7 +4838,7 @@ fn the_relay_renders_each_meeting_stone_kind() { let row = group_event(kind, other_guid, payload); let packets = - crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); let sent: Vec<_> = packets.iter().map(wire).collect(); assert_eq!(sent, [expected], "kind {kind} payload {payload:?}"); @@ -4852,7 +4853,7 @@ fn the_relay_drops_a_meeting_stone_queue_it_cannot_encode() { for payload in ["1581", "1581,6", "x,1", "4294967295,1"] { let row = group_event(event_kind::QUEUE, 0, payload); assert!( - crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) .is_empty(), "payload {payload:?}" ); @@ -5002,7 +5003,7 @@ fn the_relay_drops_a_group_broadcast_it_cannot_decode() { ] { let row = group_event(kind, VIM, payload); assert!( - crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) .is_empty(), "kind {kind} payload {payload:?}" ); @@ -5025,7 +5026,8 @@ fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { }]; let row = group_event(event_kind::LIST, 0, &roster.encode()); - let packets = crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + let packets = + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); let mut packets = packets.into_iter(); let (Some(list), Some(icons), None) = (packets.next(), packets.next(), packets.next()) else { @@ -5043,7 +5045,8 @@ fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { roster.target_icons.clear(); let row = group_event(event_kind::LIST, 0, &roster.encode()); assert_eq!( - crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row).len(), + crate::codec::social::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) + .len(), 1 ); } diff --git a/gateway/src/world/weather_tests.rs b/gateway/src/world/weather_tests.rs index d2b9c1c1d..032656df4 100644 --- a/gateway/src/world/weather_tests.rs +++ b/gateway/src/world/weather_tests.rs @@ -153,8 +153,16 @@ fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { world_session_in_world(session_store(westfall_entity), 2); // Elwynn is forced to heavy rain; Westfall drifts into light snow of its own. - crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(ELWYNN, 1, 0.75)); - crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(WESTFALL, 2, 0.30)); + crate::codec::weather::subscriptions::relay_zone_weather( + &view, + 0, + &zone_weather_row(ELWYNN, 1, 0.75), + ); + crate::codec::weather::subscriptions::relay_zone_weather( + &view, + 0, + &zone_weather_row(WESTFALL, 2, 0.30), + ); match ServerOpcodeMessage::read_encrypted(&mut elwynn_client, &mut elwynn_dec).unwrap() { ServerOpcodeMessage::SMSG_WEATHER(m) => { diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 6994be4bf..dd5df2309 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -1103,7 +1103,7 @@ parity_test!(parity_game_emote_event, "game_emote_event", lyracore_module::Emote }); /// Every table name that has a `parity_test!` line above. The completeness guard cross-checks -/// this against `stdb/connection.rs`'s real subscription list. +/// this against the Coordinator reads and registered Protocol Family Relay declarations. const MANIFEST_TABLES: &[&str] = &[ "game_realm", // Shared-connection model: moved from the per-player AOI box onto the coordinator's global @@ -1202,6 +1202,8 @@ const MANIFEST_TABLES: &[&str] = &[ "game_creature_quest", "game_gameobject_quest", "game_character_quest", + "game_quest_event_requirement", + "game_character_quest_event_credit", "game_player_spell", "game_player_action", "game_trainer_spell", @@ -1252,71 +1254,143 @@ const MANIFEST_TABLES: &[&str] = &[ // --------------------------------------------------------------------------------------------- const CONNECTION_RS: &str = include_str!("../src/stdb/connection.rs"); +const RELAYS_RS: &str = include_str!("../src/stdb/relays.rs"); -/// Scan `source` for every `"SELECT * FROM game_"` subscription-string literal and return the -/// `game_` table names, in the order they appear. A plain substring scan rather than a -/// `regex` dependency — the subscription strings are a fixed, simple literal shape. +/// Only executable string literals in the Coordinator query function count. Comments and tests +/// cannot make an unsubscribed table appear covered. fn extract_select_star_tables(source: &str) -> Vec { - let needle = "SELECT * FROM "; - let mut out = Vec::new(); - let mut rest = source; - while let Some(pos) = rest.find(needle) { - let after = &rest[pos + needle.len()..]; - let end = after - .find(|c: char| !(c.is_alphanumeric() || c == '_')) - .unwrap_or(after.len()); - out.push(after[..end].to_string()); - rest = &after[end..]; + use syn::visit::Visit; + struct Queries(Vec); + impl<'ast> Visit<'ast> for Queries { + fn visit_lit_str(&mut self, literal: &'ast syn::LitStr) { + if let Some(query) = literal.value().strip_prefix("SELECT * FROM ") { + self.0 + .push(query.split_whitespace().next().unwrap().to_owned()); + } + } + } + let file = syn::parse_file(source).expect("valid query source"); + let mut queries = Queries(Vec::new()); + for item in file.items { + if let syn::Item::Fn(function) = item { + if function.sig.ident == "coordinator_queries" { + queries.visit_block(&function.block); + } + } } - out + queries.0 +} + +fn macro_tokens(source: &str, name: &str) -> Vec { + syn::parse_file(source) + .expect("valid Relay source") + .items + .into_iter() + .filter_map(|item| match item { + syn::Item::Macro(item) if item.mac.path.segments.last()?.ident == name => { + Some(item.mac.tokens) + } + _ => None, + }) + .flat_map(|tokens| tokens.into_iter().map(|token| token.to_string())) + .collect() +} + +fn relay_tables(source: &str) -> Vec { + let tokens = macro_tokens(source, "declare_relays"); + assert_eq!( + tokens.len() % 3, + 0, + "each Relay declares a table, Realm-core choice and callbacks" + ); + tokens + .chunks_exact(3) + .map(|entry| { + assert!(entry[0].starts_with("game_")); + assert!(entry[1].starts_with('[')); + assert!(entry[2].starts_with('{')); + entry[0].clone() + }) + .collect() +} + +fn missing_manifest_tables(tables: &[String], manifest: &[&str]) -> Vec { + tables + .iter() + .filter(|table| !manifest.contains(&table.as_str())) + .cloned() + .collect() } #[test] -fn completeness_guard_extraction_flags_a_missing_manifest_entry() { - // Synthetic input (NOT the real connection.rs) proving the extraction+diff logic itself - // works, independent of today's real subscription list. - let fake = r#" - .subscribe(vec![ - "SELECT * FROM game_real_one", - "SELECT * FROM game_totally_fake_table", - ]); +fn completeness_inventory_detects_an_unmanifested_relay() { + let source = r#" + // game_comment_only [false] { insert(Snapshot) => ignored; } + declare_relays! { + game_real_one [false] { insert(Snapshot) => real_callback; } + game_missing_relay [true] { insert(Live) => missing_callback; } + } + #[cfg(test)] + mod tests { declare_relays! { game_test_only [false] {} } } "#; - let subscribed = extract_select_star_tables(fake); - let manifest = ["game_real_one"]; - let missing: Vec<&String> = subscribed - .iter() - .filter(|t| !manifest.contains(&t.as_str())) - .collect(); assert_eq!( - missing, - vec!["game_totally_fake_table"], - "the completeness-guard extraction/diff should flag exactly the one subscribed table \ - missing from the manifest" + missing_manifest_tables(&relay_tables(source), &["game_real_one"]), + ["game_missing_relay"], ); } #[test] -fn every_subscribed_table_in_connection_rs_has_a_parity_manifest_entry() { - let subscribed = extract_select_star_tables(CONNECTION_RS); +fn completeness_inventory_detects_an_unmanifested_read() { + let source = r#" + // SELECT * FROM game_comment_only + fn coordinator_queries() { + ["SELECT * FROM game_real_one", "SELECT * FROM game_missing_read WHERE pending = true"]; + } + #[cfg(test)] + fn unrelated_test() { "SELECT * FROM game_test_only"; } + "#; + assert_eq!( + missing_manifest_tables(&extract_select_star_tables(source), &["game_real_one"]), + ["game_missing_read"], + ); +} + +#[test] +fn every_coordinator_subscription_has_a_parity_manifest_entry() { + let mut subscribed = extract_select_star_tables(CONNECTION_RS); + let codec = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/codec"); + let protocol_families = macro_tokens(RELAYS_RS, "protocol_relays"); assert!( - subscribed.len() >= 30, - "sanity check: found only {} `SELECT * FROM game_...` subscriptions in connection.rs — \ - did the subscription-string format change? (the extraction scan needs updating)", - subscribed.len() + !protocol_families.is_empty(), + "the runtime Relay registration list must be scanned" ); - let missing: Vec<&String> = subscribed - .iter() - .filter(|t| !MANIFEST_TABLES.contains(&t.as_str())) - .collect(); + for family in protocol_families.into_iter().filter(|token| token != ",") { + let source = std::fs::read_to_string(codec.join(family).join("subscriptions.rs")).unwrap(); + let tables = relay_tables(&source); + assert!( + !tables.is_empty(), + "registered Protocol Family has no Relay declarations" + ); + subscribed.extend(tables); + } + assert!( + subscribed.len() >= 132, + "Coordinator query inventory lost sources" + ); + let missing = missing_manifest_tables(&subscribed, MANIFEST_TABLES); assert!( missing.is_empty(), - "connection.rs subscribes {missing:?} but gateway/tests/schema_parity.rs has no \ - `parity_test!` line for it — add one (copy the pattern from any existing entry above) \ - AND add the table name to MANIFEST_TABLES, or a gateway binding drift on this table will \ - break live BSATN decode silently." + "Coordinator subscribes to {missing:?}; add parity_test! entries and MANIFEST_TABLES names" ); } +parity_test!(parity_game_quest_event_requirement, "game_quest_event_requirement", lyracore_module::QuestEventRequirement, bindings::quest_event_requirement_type::QuestEventRequirement, { + id, quest_entry, +}); +parity_test!(parity_game_character_quest_event_credit, "game_character_quest_event_credit", lyracore_module::CharacterQuestEventCredit, bindings::character_quest_event_credit_type::CharacterQuestEventCredit, { + id, character_guid, quest_entry, +}); + parity_test!(parity_game_item_random_property, "game_item_random_property", lyracore_module::ItemRandomProperty, bindings::item_random_property_type::ItemRandomProperty, { property_id, enchant_id_1, enchant_id_2, enchant_id_3, suffix, }); diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 927b478b0..98dfd5c1e 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -1,7 +1,7 @@ //! Stream 1 (client DBC) — Spell.dbc → `game_spell` + `game_spell_effect` importer. Reads the //! operator's OWN `Spell.dbc` (+ the SpellCastTimes / SpellRange / SpellDuration / SpellRadius aux //! tables) IN MEMORY via the same `open_chain`/`read_table` path as `dbc.rs`, maps each spell's -//! header + up to 3 effects onto our deduped effect/aura TAXONOMY (module/src/spell/taxonomy.rs), +//! header + up to 3 effects onto our deduped effect/aura taxonomy in lyracore-shared, //! and emits derived `game_*` rows as chunked clear+reload SQL. It is the stress test of the //! taxonomy: every effect either maps to a REAL kind or falls back to `E_SCRIPTED` (a queryable //! no-op), and the run prints a COVERAGE REPORT measuring how much real vanilla spell data the @@ -31,47 +31,28 @@ use crate::spell_snapshot::{write_snapshot, SpellEffectRow, SpellHeaderRow, Spel use crate::{push_insert, run_sql_statements, stamp_family, Args}; use std::path::Path; -// =========================================================================================== -// TAXONOMY constants — verbatim from module/src/spell/taxonomy.rs (the emit target). Duplicated -// here (not shared) because the wasm `module` is NEVER a dependency of this native ETL tool; these -// are pure numeric tags and the runtime is the source of truth — keep the two lists in lockstep. -// =========================================================================================== - -// instant effects (high bit clear) -const E_DAMAGE: u8 = 0x01; -const E_HEAL: u8 = 0x02; -const E_ENERGIZE: u8 = 0x03; -const E_DISPEL: u8 = 0x04; -const E_TRIGGER: u8 = 0x05; -const E_TAUNT: u8 = 0x06; -const E_CREATE_ITEM: u8 = 0x07; -const E_CHARGE: u8 = 0x09; // rush into melee (Charge — vanilla effect 96, reclassified by name); engine teleports the caster -const E_CONVERT_RESOURCE: u8 = 0x0A; // drain caster health -> power (Life Tap); reclassified from SCRIPT_EFFECT -const E_JUDGEMENT: u8 = 0x0B; // unleash the active seal (Judgement); reclassified from SCRIPT_EFFECT -const E_ADD_COMBO: u8 = 0x0C; // rogue combo GENERATOR (Sinister Strike/Gouge/Backstab); reclassified from the inert Dummy effect -const E_FINISHER_DAMAGE: u8 = 0x0D; // rogue FINISHER damage (Eviscerate): scales with combo points; reclassified from E_DAMAGE -const E_RESURRECT: u8 = 0x0E; // revive a dead ally (Resurrection); reclassified from SCRIPT_EFFECT -const E_SCRIPTED: u8 = 0x0F; -const E_PICKPOCKET: u8 = 0x10; // grant the rogue copper from a creature without engaging (Pick Pocket); reclassified from SCRIPT_EFFECT -const E_INTERRUPT: u8 = 0x11; // cancel the target's in-progress cast (Kick); reclassified from the raw vanilla InterruptCast effect (68) -const E_REDUCE_THREAT: u8 = 0x12; // one-time CURRENT-threat drop on the caster (Feint); reclassified from SCRIPT_EFFECT (lockstep with module taxonomy) -const E_NEXT_SWING: u8 = 0x13; // QUEUE the strike onto the caster's next melee swing (Heroic Strike/Cleave); reclassified from E_DAMAGE by name (lockstep with module taxonomy) -const E_SET_STANCE: u8 = 0x14; // set the caster's Warrior stance (Battle/Defensive/Berserker Stance); reclassified from the inert ModShapeshift→A_FLAG marker BY NAME, with p0 = the 0-based stance id (form id − FORM_BATTLE) (lockstep with module taxonomy) -const E_SUMMON_PET: u8 = 0x15; // summon a persistent pet creature owned by the caster (Summon Imp); mapped from the raw vanilla Summon effect (56), with the misc_value (the summoned creature entry) routed into p0 (p0_kind = P_ENTRY) (lockstep with module taxonomy) -const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay on Hands); mapped from the raw vanilla HealMaxHealth effect (67), split out of E_HEAL because its base_points is ~0 (the magnitude is "fill to max", not a flat N) (lockstep with module taxonomy) -const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame; raw vanilla TameCreature effect (55), lockstep with module taxonomy -const E_FEED_PET: u8 = 0x21; // feed a Hunter pet from the explicit item target; raw effect 101 -const E_POWER_BURN: u8 = 0x19; -const E_RECALL_HOME: u8 = 0x1F; -const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink); reclassified BY NAME from the dead SCRIPT teleport effect (lockstep with module taxonomy) -const E_PERSISTENT_AREA: u8 = 0x1B; // ground-AoE (Consecration): spawns a fixed-position game_ground_area whose tick damages hostiles inside; reclassified BY NAME from the ground A_PERIODIC_DAMAGE effect (lockstep with module taxonomy) -const E_OPEN_LOCK: u8 = 0x1D; -const E_DUEL: u8 = 0x22; // Duel (raw effect 83): p0 is the duel-flag gameobject template entry -const E_DISENCHANT: u8 = 0x18; -const E_DISMOUNT: u8 = 0x23; // remove the target's active land mount (Dazed's mount-removal half): translated from a raw DISPEL_MECHANIC effect (108) whose misc value names the mount mechanic (21) — see `dismount_effect_kind` below. No params. Lockstep with module taxonomy. -const E_SUMMON_HOSTILE: u8 = 0x24; // temporary ownerless summon; p0 = creature entry, p1 = required spell focus, header duration = lifetime -const E_DISTRACT: u8 = 0x25; // turn idle enemy Creatures near the ground point toward it; raw effect 69, amount = seconds (lockstep with module taxonomy) -const E_SELF_RESURRECT: u8 = 0x26; // raw effect 94: base_points < 0 is flat health with p0 (P_FLAT_MANA) mana, else a percent of max; die_sides ignored (lockstep with module taxonomy) +use lyracore_shared::spell::taxonomy::{ + A_ABSORB, A_COMBAT_HEALTH_REGEN_PCT, A_CONTROL, A_DISARM, A_FLAG, A_IMMUNITY, A_MOD_COMBAT, + A_MOD_DAMAGE_TAKEN, A_MOD_DETECT_RANGE, A_MOD_HEALTH_POWER, A_MOD_RESISTANCE, A_MOD_SPEED, + A_MOD_STAT, A_MOD_STAT_PCT, A_MOUNTED, A_PERIODIC_DAMAGE, A_PERIODIC_ENERGIZE, A_PERIODIC_HEAL, + A_PERIODIC_TRIGGER, A_PROC_DAMAGE, A_PROC_TRIGGER, A_RETALIATE, A_SEAL, A_SELF_RESURRECT, + A_SPELLMOD_FLAT, A_SPELLMOD_PCT, A_STEALTH, COMBAT_ATTACK_POWER, COMBAT_CRIT, COMBAT_DMG_DONE, + COMBAT_DODGE, COMBAT_HIT, COMBAT_SPELL_POWER, COMBAT_THREAT, E_ADD_COMBO, E_BLINK, E_CHARGE, + E_CONVERT_RESOURCE, E_CREATE_ITEM, E_DAMAGE, E_DISENCHANT, E_DISMOUNT, E_DISPEL, E_DISTRACT, + E_DUEL, E_ENERGIZE, E_FEED_PET, E_FINISHER_DAMAGE, E_FISH, E_HEAL, E_HEAL_MAX_HEALTH, + E_INTERRUPT, E_JUDGEMENT, E_NEXT_SWING, E_OPEN_LOCK, E_PERSISTENT_AREA, E_PICKPOCKET, + E_POWER_BURN, E_RECALL_HOME, E_REDUCE_THREAT, E_RESURRECT, E_SCRIPTED, E_SELF_RESURRECT, + E_SET_STANCE, E_SUMMON_HOSTILE, E_SUMMON_PET, E_TAME_CREATURE, E_TAUNT, E_TRIGGER, M_FEAR, + M_POLY, M_ROOT, M_STUN, P_COMBAT_FIELD, P_DISPLAY_ID, P_ENTRY, P_FLAG, P_FLAT_MANA, + P_GAMEOBJECT_ENTRY, P_ITEM_ENTRY, P_MECHANIC, P_NONE, P_PCT_MAX_POWER, P_POWER_TYPE, P_RAW, + P_SCHOOL_MASK, P_SPEED_KIND, P_SPELLMOD_OP, P_SPELL_ID, P_STAT_ID, SPEED_MOUNTED, SPEED_MOVE, + SPELL_ATTR_CHANNELED, SPELL_ATTR_FINISHER_DURATION, SPELL_ATTR_INCAP_OPENER, + SPELL_ATTR_RANGED_AUTO_REPEAT, SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_DAGGER, + SPELL_ATTR_REQ_OVERPOWER, SPELL_ATTR_REQ_REVENGE, SPELL_ATTR_REQ_STEALTH, + SPELL_ATTR_STEALTH_SAFE, STANCE_BATTLE, STANCE_BEAR, STANCE_BERSERKER, STANCE_CAT, + STANCE_DEFENSIVE, STANCE_DIRE_BEAR, STAT_ALL, T_AREA_ALLY, T_AREA_ENEMY, T_SCRIPTED, T_SELF, + T_TARGET_ALLY, T_TARGET_ANY, T_TARGET_ENEMY, +}; // cmangos/classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb: // spell_template 7728/8674 send events 1131/1134; dbscripts_on_event summons @@ -84,110 +65,10 @@ fn binding_summon_entry(spell_id: u32, effect: i32, event: u32, focus: u32) -> O } } -// aura effects (high bit set) -const A_PERIODIC_DAMAGE: u8 = 0x90; -const A_PERIODIC_HEAL: u8 = 0x91; -const A_PERIODIC_ENERGIZE: u8 = 0x92; -const A_MOD_STAT: u8 = 0xA0; -const A_MOD_STAT_PCT: u8 = 0xAA; // PERCENT stat mod (The Human Spirit +5% Spirit) — folded as a multiplier -const A_MOD_RESISTANCE: u8 = 0xA1; -const A_ABSORB: u8 = 0xA2; -const A_MOD_COMBAT: u8 = 0xA3; -const A_MOD_SPEED: u8 = 0xA4; -const A_MOD_HEALTH_POWER: u8 = 0xA5; -const A_MOD_DAMAGE_TAKEN: u8 = 0xA6; -const A_SEAL: u8 = 0xA7; // proc-on-swing holy seal (Seal of Righteousness); reclassified from A_FLAG -const A_STEALTH: u8 = 0xA8; // stealth presence marker (Stealth); reclassified from A_FLAG - // A Proc that starts a Triggered Cast of its own trigger spell (vanilla Spell.dbc aura 42, - // ProcTriggerSpell). Native mapping — no name rescue (lockstep with module taxonomy, which renamed - // this wire value from A_PROC_ON_HIT). -const A_PROC_TRIGGER: u8 = 0xAB; -const A_SPELLMOD_FLAT: u8 = 0xAC; // spell modifier FLAT (DBC aura 107 AddFlatModifier — 264): p0 = SpellModOp, p1 = affected-spell family mask -const A_SPELLMOD_PCT: u8 = 0xAD; // spell modifier PERCENT (DBC aura 108 AddPctModifier) -const A_DISARM: u8 = 0xAE; // Warrior Disarm (DBC AuraMod 67 ModDisarm): strips the enemy's main-hand weapon (lockstep with module taxonomy) -const A_RETALIATE: u8 = 0xAF; // Warrior Retaliation self-buff: free counter-swing at any melee attacker; reclassified from the inert A_FLAG marker BY NAME (lockstep with module taxonomy) -const A_CONTROL: u8 = 0xB0; -const A_IMMUNITY: u8 = 0xB1; -const A_MOD_DETECT_RANGE: u8 = 0xB2; // Priest Mind Soothe (DBC AuraMod 91 ModDetectRange): reduces a creature's aggro/detection radius by `amount` yards (lockstep with module taxonomy) -const A_FLAG: u8 = 0xBE; -const A_COMBAT_HEALTH_REGEN_PCT: u8 = 0xA9; // X% of normal health regen continues during combat (lockstep with module taxonomy) -const A_MOUNTED: u8 = 0xB3; // land mount, the state of record (vanilla AuraMod Mounted, 78): p0 = the resolved creature DISPLAY id (p0_kind = P_DISPLAY_ID), frozen at import (lockstep with module taxonomy) - // A Proc that deals its frozen amount as damage of the proc spell's own school (vanilla Spell.dbc - // aura 43, ProcTriggerDamage). p0 = the header's school_mask, p0_kind = P_SCHOOL_MASK (lockstep with - // module taxonomy). -const A_PROC_DAMAGE: u8 = 0xB4; -const A_SELF_RESURRECT: u8 = 0xB5; // Soulstone Resurrection: p0 (P_SPELL_ID) becomes the Self-Resurrection Option at death (lockstep with module taxonomy) - -// MECHANICS (p0 for A_CONTROL when p0_kind == P_MECHANIC) -const M_STUN: i32 = 1; -const M_ROOT: i32 = 2; -const M_FEAR: i32 = 3; -const M_POLY: i32 = 4; const GOUGE_RANK_IDS: [u32; 5] = [1776, 1777, 8629, 11285, 11286]; -// p0_kind tags -const P_NONE: u8 = 0; -const P_STAT_ID: u8 = 1; -const P_SCHOOL_MASK: u8 = 2; -const P_MECHANIC: u8 = 3; -const P_POWER_TYPE: u8 = 4; -const P_COMBAT_FIELD: u8 = 5; -const P_SPEED_KIND: u8 = 6; -const P_FLAG: u8 = 7; -const P_ITEM_ENTRY: u8 = 8; -const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry -const P_SPELLMOD_OP: u8 = 11; // p0 is a SpellModOp (A_SPELLMOD_*) -const P_PCT_MAX_POWER: u8 = 12; // the effect's `amount` is a PERCENT of the caster's max power (Evocation); aura_apply converts it to an absolute per-tick (lockstep with module taxonomy) -const P_GAMEOBJECT_ENTRY: u8 = 13; // p0 is a game_gameobject_template entry (E_DUEL) -const P_DISPLAY_ID: u8 = 14; // p0 is a creature DISPLAY id (A_MOUNTED — the UNIT_FIELD_MOUNTDISPLAYID value) -const P_SPELL_ID: u8 = 15; // p0 is a game_spell id (A_SELF_RESURRECT) -const P_FLAT_MANA: u8 = 16; // p0 is a flat mana amount (E_SELF_RESURRECT) -const P_RAW: u8 = 255; - -// TargetKind -const T_SELF: u8 = 0; -const T_TARGET_ENEMY: u8 = 1; -const T_TARGET_ALLY: u8 = 2; -const T_TARGET_ANY: u8 = 3; -const T_AREA_ENEMY: u8 = 4; -const T_AREA_ALLY: u8 = 5; -const T_SCRIPTED: u8 = 7; - -// combat fields (p0 for A_MOD_COMBAT) -const COMBAT_ATTACK_POWER: i32 = 0; -const COMBAT_CRIT: i32 = 1; -const COMBAT_HIT: i32 = 2; -const COMBAT_DMG_DONE: i32 = 3; -// (COMBAT_HASTE = 4 is intentionally absent: haste/attack-speed auras route to A_MOD_SPEED with a -// swing/cast speed-kind, per the aura-map design — never A_MOD_COMBAT.) -const COMBAT_SPELL_POWER: i32 = 5; -// defender / threat fields (values lockstep with module/src/spell/taxonomy.rs 101-105) -const COMBAT_DODGE: i32 = 7; -const COMBAT_THREAT: i32 = 10; - -// stat ids -const STAT_ALL: i32 = 0xFF; - -// speed kinds (p0 for A_MOD_SPEED; signed PERCENT amount) — lockstep with module/src/spell/taxonomy.rs. -const SPEED_MOVE: i32 = 0; -const SPEED_MOUNTED: i32 = 3; // ModIncreaseMountedSpeed family; T4 folds this speed kind at the combat seam - -// STANCE/FORM ids (vanilla SpellShapeshiftForm.dbc indices) — the value the ModShapeshift effect's -// `effect_misc_value` carries (so a stance/form spell arrives as A_FLAG with p0 = the form id). Our -// engine uses its OWN small 0-based stance id space; `form_to_stance` below is THE one form→stance -// mapping (widened it past the Warrior trio to the Druid combat forms), consumed by BOTH -// the E_SET_STANCE p0 remap (`stance_p0`) and the `Stances` usability-mask fold (`translate_stance_mask`) -// so the two can never drift. The Spell.dbc `Stances` usability mask is a form-BIT mask -// (`1 << (formId-1)`: bit0=Cat/bit4=Bear/bit7=DireBear/bit16=Battle/bit17=Defensive/bit18=Berserker); -// each mapped form bit folds onto our stance bit (`1 << stance`) for the header `stances` column. -// The full convention (LOCKSTEP with module/src/spell/taxonomy.rs STANCE_* — that comment block is the -// definition site): -// form 17 Battle → stance 0 form 5 Bear → stance 3 -// form 18 Defensive → stance 1 form 1 Cat → stance 4 -// form 19 Berserker → stance 2 form 8 Dire Bear → stance 5 -// Unmapped forms (Aquatic 4 / Travel 3 / Tree 2 / Ghoul 7 / Moonkin 31 / …) stay out of scope: their -// mask bits are DROPPED (a spell usable ONLY in an unmapped form imports with the mapped-form bits it -// has, or 0 = "any stance"). +// Vanilla form IDs differ from durable stance IDs. Both effect parameters and +// usability masks use this mapping; unsupported forms contribute no stance bit. const FORM_CAT: i32 = 1; const FORM_BEAR: i32 = 5; const FORM_DIRE_BEAR: i32 = 8; @@ -195,46 +76,24 @@ const FORM_BATTLE: i32 = 17; const FORM_DEFENSIVE: i32 = 18; const FORM_BERSERKER: i32 = 19; -/// THE one vanilla ShapeshiftForm id → our 0-based stance id mapping (see the FORM_* block above; the -/// module's `taxonomy.rs` STANCE_* consts are the lockstep definition site). `None` = an out-of-scope -/// form (Aquatic/Travel/Tree/…) — dropped from usability masks, never produced by the E_SET_STANCE -/// name rescue. Pure. [import] fn form_to_stance(form_id: i32) -> Option { match form_id { - FORM_BATTLE => Some(0), - FORM_DEFENSIVE => Some(1), - FORM_BERSERKER => Some(2), - FORM_BEAR => Some(3), - FORM_CAT => Some(4), - FORM_DIRE_BEAR => Some(5), + FORM_BATTLE => Some(i32::from(STANCE_BATTLE)), + FORM_DEFENSIVE => Some(i32::from(STANCE_DEFENSIVE)), + FORM_BERSERKER => Some(i32::from(STANCE_BERSERKER)), + FORM_BEAR => Some(i32::from(STANCE_BEAR)), + FORM_CAT => Some(i32::from(STANCE_CAT)), + FORM_DIRE_BEAR => Some(i32::from(STANCE_DIRE_BEAR)), _ => None, } } -// Rogue cast-gate flags — OUR OWN bits in the DEDICATED game_spell.cast_flags column, set BY NAME below -// (lockstep with module/src/spell/taxonomy.rs SPELL_ATTR_*). A separate column from the raw vanilla -// `attributes` (whose bits are densely used and would collide). The engine gates on these in -// resolve_cast_at. -const SPELL_ATTR_REQ_BEHIND: u32 = 0x0001; // must be cast from behind the target (Backstab) -const SPELL_ATTR_REQ_STEALTH: u32 = 0x0002; // must be cast while stealthed (Sap) -const SPELL_ATTR_STEALTH_SAFE: u32 = 0x0004; // casting it does NOT break stealth (Sap, Pick Pocket, Distract) -const SPELL_ATTR_FINISHER_DURATION: u32 = 0x0008; // combo-finisher whose aura DURATION scales with combo points (Slice and Dice) -const SPELL_ATTR_INCAP_OPENER: u32 = 0x0010; // Sap-shaped incapacitate opener: additionally require the target OUT of combat + HUMANOID (Sap). Split OUT of REQ_STEALTH so Garrote (a stealth opener on ANY type, usable in combat) carries REQ_STEALTH without these constraints -const SPELL_ATTR_REQ_OVERPOWER: u32 = 0x0020; // Overpower: castable only in the ~5s window after the caster's swing was DODGED (Tier 2b react window) -const SPELL_ATTR_REQ_REVENGE: u32 = 0x0040; // Revenge: castable only in the ~5s window after the caster DODGED/PARRIED/BLOCKED an incoming swing (Tier 2b react window) -const SPELL_ATTR_CHANNELED: u32 = 0x0080; // CHANNELED (Arcane Missiles): the cast ticks a per-tick effect over duration_ms and breaks on action — set from the DBC AttributesEx1 CHANNELED bit (0x44), by-NAME fallback. Drives the A_PERIODIC_TRIGGER reclassify below -const SPELL_ATTR_REQ_DAGGER: u32 = 0x0100; // Backstab: castable only with a DAGGER equipped in the main hand (lockstep with module/src/spell/taxonomy.rs) -const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; - // vanilla `AttributesEx1` CHANNELED bits — IS_CHANNELLED (0x04) | CHANNELED_2 (0x40). cmangos: channeled = AttributesEx1 & 0x44. const ATTR_EX1_CHANNELED_MASK: u32 = 0x0000_0044; const ATTR_EX1_EXCLUDE_CASTER: u32 = 0x0008_0000; // vanilla `AttributesEx2` SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20) — marks auto-repeat ranged attacks (Auto Shot, wand Shoot). const ATTR_EX2_AUTOREPEAT: u32 = 0x0000_0020; -// the A_PERIODIC_TRIGGER aura kind (lockstep with module/src/spell/taxonomy.rs) — a CHANNEL's per-tick trigger. -const A_PERIODIC_TRIGGER: u8 = 0x93; - // vanilla SpellEffect numeric IDs that PLACE an aura → branch to the AuraMod map. const EFFECT_APPLY_AURA: i32 = 6; const EFFECT_PERSISTENT_AREA_AURA: i32 = 27; @@ -800,7 +659,7 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { // Flat and percentage modifiers both select the stat from misc. u32::MAX selects all. A_MOD_STAT | A_MOD_STAT_PCT => { let p0 = if misc == u32::MAX { - STAT_ALL + i32::from(STAT_ALL) } else { (misc & 0xFF) as i32 }; @@ -819,20 +678,20 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { ModDamageDone | ModDamagePercentDone | ModDamageDoneCreature - | ModDamageDoneVersus => COMBAT_DMG_DONE, + | ModDamageDoneVersus => i32::from(COMBAT_DMG_DONE), ModCritPercent | ModSpellCritChance | ModSpellCritChanceSchool - | ModCritPercentVersus => COMBAT_CRIT, - ModHitChance | ModSpellHitChance => COMBAT_HIT, - ModHealing | ModHealingDone => COMBAT_SPELL_POWER, - ModDodgePercent => COMBAT_DODGE, // Evasion — defender +dodge (scaled to bp below) + | ModCritPercentVersus => i32::from(COMBAT_CRIT), + ModHitChance | ModSpellHitChance => i32::from(COMBAT_HIT), + ModHealing | ModHealingDone => i32::from(COMBAT_SPELL_POWER), + ModDodgePercent => i32::from(COMBAT_DODGE), // Evasion — defender +dodge (scaled to bp below) // Fade (ModTotalThreat, signed % on ALL threat) + Righteous Fury (ModThreat, +60% → // 1.6× threat, the paladin tank aura) both fold onto the COMBAT_THREAT field the // threat::add_threat multiplier already reads — zero engine code. - ModTotalThreat | ModThreat => COMBAT_THREAT, + ModTotalThreat | ModThreat => i32::from(COMBAT_THREAT), // remaining A_MOD_COMBAT auras are the attack-power family - _ => COMBAT_ATTACK_POWER, + _ => i32::from(COMBAT_ATTACK_POWER), }; (field, P_COMBAT_FIELD) } @@ -847,9 +706,9 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { ModCastingSpeedNotStack => 2, ModIncreaseSwimSpeed => 2, ModIncreaseMountedSpeed | ModMountedSpeedAlways | ModMountedSpeedNotStack => { - SPEED_MOUNTED + i32::from(SPEED_MOUNTED) } - _ => SPEED_MOVE, + _ => i32::from(SPEED_MOVE), }; (kind_v, P_SPEED_KIND) } @@ -1649,7 +1508,7 @@ fn resolve_effect_kind( // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both // (the threat fold divides by 100), so it is NOT scaled. - let base_points = if kind == A_MOD_COMBAT && p0 == COMBAT_DODGE { + let base_points = if kind == A_MOD_COMBAT && p0 == i32::from(COMBAT_DODGE) { base_points * 100 } else { base_points @@ -1822,7 +1681,6 @@ fn trainer_offering_rows( /// (not client data, firewall-clean), written by key AFTER the bulk insert so they replace whatever /// the DBC mapped at the same effect slot. fn fishing_marker_rows() -> Vec { - const E_FISH: u8 = 0x1C; // lockstep with module taxonomy [7620u32, 7731, 7732] .into_iter() .map(|spell_id| SpellEffectRow { @@ -2906,11 +2764,11 @@ mod tests { assert_eq!(aura_mod_to_kind(ModTotalThreat), A_MOD_COMBAT); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModDodgePercent, 0).0, - COMBAT_DODGE + i32::from(COMBAT_DODGE) ); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModTotalThreat, 0).0, - COMBAT_THREAT + i32::from(COMBAT_THREAT) ); } @@ -3620,23 +3478,23 @@ mod tests { use AuraMod::*; assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModAttackPower, 99), - (COMBAT_ATTACK_POWER, P_COMBAT_FIELD) + (i32::from(COMBAT_ATTACK_POWER), P_COMBAT_FIELD) ); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModCritPercent, 0), - (COMBAT_CRIT, P_COMBAT_FIELD) + (i32::from(COMBAT_CRIT), P_COMBAT_FIELD) ); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModHitChance, 0), - (COMBAT_HIT, P_COMBAT_FIELD) + (i32::from(COMBAT_HIT), P_COMBAT_FIELD) ); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModDamageDone, 0), - (COMBAT_DMG_DONE, P_COMBAT_FIELD) + (i32::from(COMBAT_DMG_DONE), P_COMBAT_FIELD) ); assert_eq!( resolve_aura_params(A_MOD_COMBAT, ModHealing, 0), - (COMBAT_SPELL_POWER, P_COMBAT_FIELD) + (i32::from(COMBAT_SPELL_POWER), P_COMBAT_FIELD) ); } @@ -3722,7 +3580,7 @@ mod tests { // misc -1 (u32::MAX) on a ModStat ⇒ all-stats sentinel 0xFF. assert_eq!( resolve_aura_params(A_MOD_STAT, ModStat, u32::MAX), - (STAT_ALL, P_STAT_ID) + (i32::from(STAT_ALL), P_STAT_ID) ); assert_eq!(resolve_aura_params(A_MOD_STAT, ModStat, 3), (3, P_STAT_ID)); // INT // ModStat is FLAT; ModTotalStatPercentage / ModPercentStat are PERCENT (distinct kind). BOTH resolve @@ -3735,7 +3593,7 @@ mod tests { ); // Spirit assert_eq!( resolve_aura_params(A_MOD_STAT_PCT, ModTotalStatPercentage, u32::MAX), - (STAT_ALL, P_STAT_ID) + (i32::from(STAT_ALL), P_STAT_ID) ); // resistance keeps the 7-bit school mask. assert_eq!( @@ -3943,7 +3801,7 @@ mod tests { ] { assert_eq!( resolve_aura_params(A_MOD_SPEED, mounted, 0), - (SPEED_MOUNTED, P_SPEED_KIND) + (i32::from(SPEED_MOUNTED), P_SPEED_KIND) ); } // Vanilla stores the flat amount MINUS one; 59/99 normalize to the nominal 60%/100%. diff --git a/module/src/actor.rs b/module/src/actor.rs index bddf8a354..8849ad52b 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -336,3 +336,5 @@ pub(crate) fn stop_movement(ctx: &ReducerContext, actor_guid: u64) { ctx.db.game_creature_spline().guid().delete(actor_guid); } } + +package_only! { pub(crate) use lyracore_shared::spell::taxonomy::EffectKind as EffectKind; } diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 9eb15e946..7d8b1bfdd 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -232,13 +232,10 @@ pub(crate) fn effective_agility(ctx: &ReducerContext, e: &WorldEntity) -> u32 { /// incoming hits. Clamped ≥0; no aura and no armor gear → the base value (mitigation byte-identical to /// before — baseline-safe). [entity] pub(crate) fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { - (i64::from(e.armor) - + i64::from(crate::spell::resistance_bonus( - ctx, - e.guid, - crate::spell::RESIST_ARMOR, - ))) - .clamp(0, i64::from(u32::MAX)) as u32 + lyracore_shared::resistance::effective_resistance( + e.armor, + crate::spell::resistance_bonus(ctx, e.guid, crate::spell::RESIST_ARMOR), + ) } /// A unit's EFFECTIVE shield BLOCK VALUE — the flat physical damage a blocked swing absorbs: the @@ -327,19 +324,19 @@ pub(crate) fn effective_swing_time(ctx: &ReducerContext, attacker: &WorldEntity) /// movement and the anti-cheat max-speed check (`world::score_and_log_movement`), so a mounted player's /// higher speed is never flagged as a speedhack. No move/mount aura → exactly `base` (baseline-safe). [entity] pub(crate) fn effective_move_speed(ctx: &ReducerContext, guid: u64, base: f32) -> f32 { - let move_pct = crate::spell::speed_bonus(ctx, guid, crate::spell::SPEED_MOVE); - let mounted_pct = crate::spell::speed_bonus(ctx, guid, crate::spell::SPEED_MOUNTED); - let mounted = crate::mount::active_mount_spell(ctx, guid).is_some(); - move_speed_with_pct(base, mounted_move_pct(move_pct, mounted_pct, mounted)) + lyracore_shared::aura::run_speed( + base, + crate::spell::auras_on(ctx, guid).map(|aura| lyracore_shared::aura::AuraAmount { + kind: aura.eff_kind, + parameter: aura.eff_p0, + amount: aura.amount, + stacks: aura.stacks, + }), + ) } -/// The mounted-speed fold's PURE core: `SPEED_MOVE` always contributes; `SPEED_MOUNTED` only while -/// `mounted` — so dismounting drops the mount's bonus but an ordinary buff/snare stays folded in, and a -/// leftover `SPEED_MOUNTED` reading (e.g. stale test data) never registers while `A_MOUNTED` is absent. -/// Pure — unit-tested without a `ReducerContext`. [pure] -fn mounted_move_pct(move_pct: i32, mounted_pct: i32, mounted: bool) -> i32 { - move_pct + if mounted { mounted_pct } else { 0 } -} +#[cfg(test)] +use lyracore_shared::aura::mounted_move_pct; /// The player's equipped WEAPON profile in inventory `slot`: `(damage_min, damage_max, delay_ms, /// subclass)`, or `None` when the slot is EMPTY, its instance has no template, it holds a NON-weapon @@ -419,20 +416,19 @@ pub(crate) fn equipped_ranged_weapon( /// fires (we don't enforce the bow→arrow / gun→bullet subclass pairing); any ammo is fine for v1. pub(crate) fn find_ammo(ctx: &ReducerContext, player_guid: u64) -> Option { let templates = ctx.db.game_item_template(); - ctx.db - .game_item_instance() - .by_owner_guid() - .filter(&player_guid) - .filter(|i| { - // Banked ammo is not loaded — only carried stacks fire. - crate::items::is_carried_slot(i.slot) - && i.stack_count > 0 - && templates - .entry() - .find(i.entry) - .is_some_and(|t| t.class == 6) - }) - .min_by_key(|i| i.slot) + lyracore_shared::item::loaded_ammo( + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(&player_guid), + |item| { + ( + item.slot, + item.stack_count, + templates.entry().find(item.entry).map(|tmpl| tmpl.class), + ) + }, + ) } /// Per-swing percent chance the main-hand weapon loses 1 durability. Vanilla wears weapons SLOWLY (the diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 72d8c6796..801164c27 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -35,6 +35,8 @@ pub(crate) use death::{ kill_player, lethal_floor_protects, set_lethal_damage_floor, Hit, HitSource, CORPSE_DECAY_MICROS, DRAIN_SOUL_SPELL_ID, RESPAWN_MICROS, SOUL_SHARD_ENTRY, }; +#[cfg(test)] +pub(crate) use lyracore_shared::aura::move_speed_with_pct; #[cfg(test)] pub(crate) use engage::COMBAT_DROP_MS; @@ -66,15 +68,15 @@ pub(crate) use tables::{ agility_crit_bp, apply_offhand_penalty, armor_mitigation_pct, attack_outcome, block_chance_bp, crit_bp_with_bonus, crushing_chance_bp, dodge_chance_bp, effective_stat, glancing_chance_bp, is_rage_user, is_translating, melee_attack_power_for, miss_bp_with_bonus, miss_chance_bp, - move_speed_with_pct, parry_chance_bp, player_swing_range_ap, rage_from_damage, - ranged_sheet_values, regen_entity_health, regen_entity_health_in_combat, regen_entity_power, - resist_mitigation_pct, scale_swing_for_rank, swing_time_with_haste, weapon_swing_range_ap, - AttackTable, RegenWindow, AUTO_SHOT_PROJECTILE_SPEED, COMBAT_BACKSTOP_SQ, CREATURE_MELEE_MAX, - CREATURE_MELEE_MIN, CRIT_MULT, CRUSH_DAMAGE_PCT, GLANCING_DAMAGE_PCT, HIT_BLOCK, HIT_CRIT, - HIT_CRUSHING, HIT_DODGE, HIT_GLANCING, HIT_MISS, HIT_NORMAL, HIT_PARRY, LEASH_RADIUS_SQ, - MELEE_LEEWAY_WINDOW_MS, MELEE_RANGE_LEEWAY_CREATURE_SQ, MELEE_RANGE_LEEWAY_SQ, MELEE_RANGE_SQ, - MOVE_FLAG_FORWARD, MOVE_MASK_MOVING, PURSUIT_WINDOW_MS, RANGED_INITIAL_SHOT_MS, - RANGED_RANGE_SQ, WAND_PROJECTILE_SPEED, + parry_chance_bp, player_swing_range_ap, rage_from_damage, ranged_sheet_values, + regen_entity_health, regen_entity_health_in_combat, regen_entity_power, resist_mitigation_pct, + scale_swing_for_rank, swing_time_with_haste, weapon_swing_range_ap, AttackTable, RegenWindow, + AUTO_SHOT_PROJECTILE_SPEED, COMBAT_BACKSTOP_SQ, CREATURE_MELEE_MAX, CREATURE_MELEE_MIN, + CRIT_MULT, CRUSH_DAMAGE_PCT, GLANCING_DAMAGE_PCT, HIT_BLOCK, HIT_CRIT, HIT_CRUSHING, HIT_DODGE, + HIT_GLANCING, HIT_MISS, HIT_NORMAL, HIT_PARRY, LEASH_RADIUS_SQ, MELEE_LEEWAY_WINDOW_MS, + MELEE_RANGE_LEEWAY_CREATURE_SQ, MELEE_RANGE_LEEWAY_SQ, MELEE_RANGE_SQ, MOVE_FLAG_FORWARD, + MOVE_MASK_MOVING, PURSUIT_WINDOW_MS, RANGED_INITIAL_SHOT_MS, RANGED_RANGE_SQ, + WAND_PROJECTILE_SPEED, }; #[cfg(feature = "debug_reducers")] pub(crate) use tables::{regen_health_in_combat, swing_profile}; diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 0f5a41145..449010f2e 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -325,9 +325,8 @@ pub(crate) fn swing_time_with_haste(base_ms: u32, haste_pct: i32) -> u32 { /// faster; negative = slower (a snare like Hamstring). Floored at 10% of base so a heavy snare slows but /// never freezes a unit dead (a zero-length step breaks the movement passes' no-op-leg skip and would /// look frozen). Pure — unit-tested. `pct == 0` → exactly `base` (baseline-safe). [pure] -pub(crate) fn move_speed_with_pct(base: f32, pct: i32) -> f32 { - base * (100 + pct).max(10) as f32 / 100.0 -} +#[cfg(test)] +use lyracore_shared::aura::move_speed_with_pct; /// Per-1000 melee-damage multiplier for a creature's classification `rank` (the swing twin of /// `creatures::rank_hp_multiplier_permille`). cmangos `creature_template.rank`: `0`=normal, `1`=elite, diff --git a/module/src/faction.rs b/module/src/faction.rs index 0e1b2a0f9..10b927ae8 100644 --- a/module/src/faction.rs +++ b/module/src/faction.rs @@ -83,15 +83,13 @@ fn relation_holds( mask: u32, other: &FactionTemplate, ) -> bool { - if other.faction != 0 { - if asserting.contains(&other.faction) { - return true; - } - if vetoing.contains(&other.faction) { - return false; - } - } - (mask & other.faction_group) != 0 + lyracore_shared::faction::relation_holds( + asserting, + vetoing, + mask, + other.faction, + other.faction_group, + ) } /// Is `a` hostile to `b`? The enemy direction of the relationship rule: an explicit enemy entry diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 6b275266f..0229da451 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -538,10 +538,7 @@ pub(crate) fn is_bank_slot(slot: u8) -> bool { /// owned. Bank slots are owned but not carried: they don't count for collect quests, aren't consumed /// on turn-in, aren't fired as ammo, and never receive auto-stored loot. Stated as the carried ranges /// rather than "not the bank", so the bank-bag region stays uncarried when it opens. -pub(crate) fn is_carried_slot(slot: u8) -> bool { - slot < BACKPACK_SLOT_END // 0..=38 (equip + bag-equip + backpack) - || (BAG_CONTENT_OFFSET..BAG_CONTENT_END).contains(&slot) // 120..=191 -} +pub(crate) use lyracore_shared::item::is_carried_slot; /// Splits target storage slots only. Equipment requires the move path's Gates. pub(crate) fn valid_split_dest_slot(to_slot: u8) -> bool { diff --git a/module/src/items/mod.rs b/module/src/items/mod.rs index 8794b7a75..f577b5112 100644 --- a/module/src/items/mod.rs +++ b/module/src/items/mod.rs @@ -52,7 +52,7 @@ pub(crate) use ops::{ apply_death_durability_loss, apply_item_use, apply_take_loot, carried_items, equipped_school_stat_bonus, equipped_stat_bonus, grant_item, grant_item_property, grant_letter_item, grant_starter_item, has_free_slot, item_count, item_stat_bonus, - remove_items, request_grant_item, request_profile_item, request_take_loot, + remove_items, request_grant_item, request_profile_item, request_take_loot, resistance_items, restore_carried_items, store_instance_state, ItemSnapshot, }; // ditto diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 9954ae000..b749d20ce 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -23,8 +23,7 @@ use super::rules::{ binds_on_grant, equip_slot, meets_required_level, resolve_equip_slot, template_stat, EquipStat, }; use super::tables::{ - game_item_instance, game_item_template, item_in_slot, item_is_broken, ItemInstance, - ItemTemplate, + game_item_instance, game_item_template, item_in_slot, ItemInstance, ItemTemplate, }; /// Grant the starter loadout to a character the first time it logs in, idempotently. Called from @@ -427,17 +426,16 @@ pub(crate) fn grant_letter_item( Ok(()) } -/// Total quantity of `item_entry` the player owns across ALL stacks (bag + equipped) — drives -/// collect-quest completion (parity). Sums `stack_count` over the owner's matching item rows. [entity] +/// Carried quantity of `item_entry` for collect-quest completion and turn-in. pub(crate) fn item_count(ctx: &ReducerContext, owner_guid: u64, item_entry: u32) -> u32 { - ctx.db - .game_item_instance() - .by_owner_guid() - .filter(&owner_guid) - // Banked copies don't count toward a collect quest. - .filter(|i| is_carried_slot(i.slot) && i.entry == item_entry) - .map(|i| i.stack_count) - .sum() + lyracore_shared::item::carried_quantity( + item_entry, + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(&owner_guid) + .map(|item| (item.slot, item.entry, item.stack_count)), + ) } /// Remove exactly `count` carried units, lowest slot first. Missing quantity leaves every item @@ -686,6 +684,48 @@ pub(crate) fn apply_item_use( .map_err(|detail| refuse(ItemRefusal::NotRightNow, detail)) } +/// Project owned item rows for the shared resistance fold. +pub(crate) fn resistance_items( + ctx: &ReducerContext, + owner_guid: u64, +) -> impl Iterator + '_ { + use crate::game_item_random_property; + use lyracore_shared::resistance::ResistanceItem; + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(owner_guid) + .filter_map(|item| { + let tmpl = ctx.db.game_item_template().entry().find(item.entry)?; + let property = (item.random_property_id != 0) + .then(|| { + ctx.db + .game_item_random_property() + .property_id() + .find(item.random_property_id) + }) + .flatten(); + Some(ResistanceItem { + slot: item.slot, + max_durability: tmpl.max_durability, + durability: item.durability, + template: [ + EquipStat::Armor, + EquipStat::HolyResistance, + EquipStat::FireResistance, + EquipStat::NatureResistance, + EquipStat::FrostResistance, + EquipStat::ShadowResistance, + EquipStat::ArcaneResistance, + ] + .map(|which| template_stat(&tmpl, which)), + enchant_id: item.enchant_id, + property_enchants: property + .map_or([0; 3], |p| [p.enchant_id_1, p.enchant_id_2, p.enchant_id_3]), + }) + }) +} + /// One item's template, applied enchantment, and Random Property contribution to a Stat Kind. fn item_stat_bonus_for_school( ctx: &ReducerContext, @@ -710,9 +750,7 @@ fn item_stat_bonus_for_school( ), ) }; - template_stat(tmpl, which) - .saturating_add(enchant) - .saturating_add(property) + lyracore_shared::item_property::item_stat(template_stat(tmpl, which), enchant, property) } pub(crate) fn item_stat_bonus( @@ -731,29 +769,28 @@ fn equipped_stat_bonus_for_school( school_mask: u32, ) -> i32 { let templates = ctx.db.game_item_template(); - ctx.db - .game_item_instance() - .by_owner_guid() - .filter(&owner_guid) - .filter(|i| i.slot <= equip_slot::END) // equipment region only (0..=18); bags/backpack don't count - .filter_map(|i| { - let tmpl = templates.entry().find(i.entry)?; - if item_is_broken(&tmpl, &i) { - return None; // a broken item grants no stats until repaired - } - // The template stat PLUS this instance's per-instance ENCHANT overlay (ENCHANTING, completing - // the 13). `enchant_stat` is 0 for an unenchanted item (enchant_id 0) → byte-identical readout - // for every existing/unenchanted piece (baseline-safe). A broken item already returned above, so - // a broken-but-enchanted piece grants neither — the enchant rides the item's working state. - Some(item_stat_bonus_for_school( - ctx, - &i, - &tmpl, - which, - school_mask, - )) - }) - .fold(0i32, i32::saturating_add) + lyracore_shared::item_property::equipped_stat( + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(&owner_guid) + .filter_map(|item| { + let tmpl = templates.entry().find(item.entry)?; + if !lyracore_shared::item::grants_equipped_stats( + item.slot, + tmpl.max_durability, + item.durability, + ) { + return None; + } + Some(( + item.slot, + tmpl.max_durability, + item.durability, + item_stat_bonus_for_school(ctx, &item, &tmpl, which, school_mask), + )) + }), + ) } /// Sum one Stat Kind across working items in the equipment region. diff --git a/module/src/items/properties.rs b/module/src/items/properties.rs index 56231d527..7bcf567cf 100644 --- a/module/src/items/properties.rs +++ b/module/src/items/properties.rs @@ -115,16 +115,15 @@ pub(crate) fn enchant_stat_for_school( if enchant_id == 0 { return 0; } - ctx.db - .game_item_enchantment() - .by_enchant() - .filter(enchant_id) - .filter(|row| { - row.kind != kind::UNKNOWN - && row.kind == stat_kind - && (row.school_mask == 0 || row.school_mask & school_mask != 0) - }) - .fold(0i32, |sum, row| sum.saturating_add(row.amount)) + kind::enchant_stat( + stat_kind, + school_mask, + ctx.db + .game_item_enchantment() + .by_enchant() + .filter(enchant_id) + .map(|row| (row.kind, row.amount, row.school_mask)), + ) } pub(crate) fn property_stat(ctx: &ReducerContext, property_id: u32, stat_kind: u8) -> i32 { @@ -148,14 +147,14 @@ pub(crate) fn property_stat_for_school( else { return 0; }; - [ - property.enchant_id_1, - property.enchant_id_2, - property.enchant_id_3, - ] - .into_iter() - .map(|id| enchant_stat_for_school(ctx, id, stat_kind, school_mask)) - .fold(0i32, i32::saturating_add) + kind::property_stat( + [ + property.enchant_id_1, + property.enchant_id_2, + property.enchant_id_3, + ], + |id| enchant_stat_for_school(ctx, id, stat_kind, school_mask), + ) } pub(crate) fn is_known_enchant(ctx: &ReducerContext, enchant_id: u32) -> bool { diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index a9ce269ee..9fe0216b8 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -300,21 +300,6 @@ pub(crate) enum EquipStat { Block, } -impl EquipStat { - pub(crate) fn resistance_for_school(school_mask: u8) -> Option { - match school_mask.trailing_zeros() { - 0 if school_mask != 0 => Some(Self::Armor), - 1 => Some(Self::HolyResistance), - 2 => Some(Self::FireResistance), - 3 => Some(Self::NatureResistance), - 4 => Some(Self::FrostResistance), - 5 => Some(Self::ShadowResistance), - 6 => Some(Self::ArcaneResistance), - _ => None, - } - } -} - /// This item template's contribution to one `EquipStat` — the single typed column the variant names. /// Pure (no ctx): the per-template projection `equipped_stat_bonus` sums across a unit's worn pieces, so /// the stat-sum arithmetic is unit-testable on a plain `ItemTemplate` without a live module. A column the @@ -710,20 +695,6 @@ pub(crate) mod tests { assert_eq!(template_stat(&blank_template(25), EquipStat::Strength), 0); } - #[test] - fn resistance_kind_uses_the_lowest_school_bit() { - assert_eq!( - EquipStat::resistance_for_school(1 << 2), - Some(EquipStat::FireResistance) - ); - assert_eq!( - EquipStat::resistance_for_school((1 << 4) | (1 << 6)), - Some(EquipStat::FrostResistance) - ); - assert_eq!(EquipStat::resistance_for_school(0), None); - assert_eq!(EquipStat::resistance_for_school(1 << 7), None); - } - #[test] fn template_stat_sums_independently_per_stat_and_is_zero_for_no_gear() { let sum = |pieces: &[ItemTemplate], which: EquipStat| -> i32 { diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 9e9fe7084..ac87860ac 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -20,7 +20,6 @@ use crate::actor::{ActionRefusal, ActionRefusalKind}; use crate::character::game_character; use crate::game_group_member; // clone_quest_loot_for_group's GameObject roster read use crate::game_world_entity; -use crate::quest::objective_kind; use crate::{game_character_quest, game_quest_objective}; // killer_needs_item (fishing's zone resolve now lives in terrain::zone_id_at) use lyracore_shared::loot::LootRefusal; use lyracore_shared::loot_roll::event_kind as roll_event_kind; // apply_loot_money's MONEY_SHARE push @@ -243,22 +242,13 @@ pub(crate) fn roll_loot_rows( .collect() } -/// Pure gate: may `taker_guid` claim a `quest_only` row whose current -/// reservation is `reserved_for`, given `taker_needs_item` (already resolved by the caller — mirrors -/// `needs_item_pure`'s ctx/pure split)? An UNRESERVED row (`reserved_for == 0` — the shared row nobody -/// has split yet) is claimable by anyone who currently needs it; a RESERVED row is claimable by its -/// reserved owner UNCONDITIONALLY — the reservation itself is the grant (need was checked when the -/// clone was minted). Re-checking need here would contradict the gateway's display filter -/// (`quest_row_visible_to_viewer`, `gateway/src/stdb/reads.rs`), which always shows a reservee their -/// row: a member whose need lapsed between clone and take (capped the count off another corpse, say) -/// would face a visible-but-untakeable row stuck until corpse decay. Same predicate shape on both -/// sides, so a row a viewer's window shows is always a row their take can actually succeed on. Pure. +/// A quest reservation grants its owner the item even when current need has ended. pub(crate) fn quest_take_allowed( reserved_for: u64, taker_guid: u64, taker_needs_item: bool, ) -> bool { - reserved_for == taker_guid || (reserved_for == 0 && taker_needs_item) + lyracore_shared::loot::quest_take_allowed(reserved_for, taker_guid, taker_needs_item) } pub(crate) fn group_loot_take_allowed( @@ -268,16 +258,13 @@ pub(crate) fn group_loot_take_allowed( designated_looter_guid: u64, taker_guid: u64, ) -> bool { - if withheld { - return false; // a live roll owns this row — nobody autostores it; the winner gets reserved_for - } - if reserved_for != 0 { - return reserved_for == taker_guid; - } - if master_only { - return false; // master-loot rows are taken ONLY via `loot_master_give`, never autostore - } - designated_looter_guid == 0 || designated_looter_guid == taker_guid + lyracore_shared::loot::group_loot_take_allowed( + withheld, + reserved_for, + master_only, + designated_looter_guid, + taker_guid, + ) } /// Pure: given `(character_guid, currently_needs_item)` pairs for every OTHER @@ -313,68 +300,41 @@ pub(crate) fn next_free_slots(used_slots: &[u8], count: usize) -> Vec { .collect() } -/// Pure decision behind `killer_needs_item`: does ANY of `objectives` (flattened `(quest_entry, kind, -/// target_entry)` triples for the killer's ACTIVE — unrewarded — quests) want `item` as a -/// `COLLECT_ITEM` target on a quest actually in `active_quests`? Split out so the decision is -/// unit-testable without a live `ReducerContext` (mirrors `can_skin`/`can_gather`'s pure-gate split). -pub(crate) fn needs_item_pure( - active_quests: &[u32], - objectives: &[(u32, u8, u32, u32)], // (quest, kind, target, required_count) - item: u32, - held: u32, -) -> bool { - objectives - .iter() - .any(|&(quest_entry, kind, target_entry, required_count)| { - kind == objective_kind::COLLECT_ITEM - && target_entry == item - && active_quests.contains(&quest_entry) - && held < required_count.max(1) - }) -} - -/// Does `killer` currently need `item` for an ACTIVE (unrewarded) quest's `COLLECT_ITEM` objective? -/// `killer = None` (a debug/environmental kill with no credited player) never needs anything — a -/// quest-only row is simply invisible there, matching vanilla (no quest, no drop). Fetches the killer's -/// live quest log + objectives, drops a Raid member's non-Raid quests first -/// ([`lyracore_shared::quest::quest_progresses_for_raid`], cm:Player.cpp:13796-13799: a normal -/// quest item does not drop for a Raid member), then defers the actual decision to the pure -/// `needs_item_pure`. +/// Read quest and inventory facts for the shared quest-item need rule. pub(crate) fn killer_needs_item(ctx: &ReducerContext, killer: Option, item: u32) -> bool { let Some(killer_guid) = killer else { return false; }; - // `in_raid` is an indexed lookup on `killer_guid` alone: computed once, not once per quest. let in_raid = crate::group::in_raid(ctx, killer_guid); let active: Vec = ctx .db .game_character_quest() .by_character() .filter(&killer_guid) - .filter(|cq| !cq.rewarded) - .map(|cq| cq.quest_entry) - .filter(|&quest_entry| { - lyracore_shared::quest::quest_progresses_for_raid( - in_raid, - crate::quest::quest_type_of(ctx, quest_entry), - ) - }) - .collect(); - if active.is_empty() { - return false; - } - let objs: Vec<(u32, u8, u32, u32)> = active - .iter() - .flat_map(|q| { - ctx.db - .game_quest_objective() - .by_quest() - .filter(q) - .map(|o| (o.quest_entry, o.kind, o.target_entry, o.required_count)) - }) + .filter(|quest| !quest.rewarded) + .map(|quest| quest.quest_entry) .collect(); - let held = crate::items::item_count(ctx, killer_guid, item); - needs_item_pure(&active, &objs, item, held) + let objectives = active.iter().flat_map(|quest_entry| { + let quest_type = crate::quest::quest_type_of(ctx, *quest_entry); + ctx.db + .game_quest_objective() + .by_quest() + .filter(quest_entry) + .map(move |objective| lyracore_shared::loot::QuestItemObjective { + quest_entry: objective.quest_entry, + quest_type, + kind: objective.kind, + target_entry: objective.target_entry, + required_count: objective.required_count, + }) + }); + lyracore_shared::loot::needs_quest_item( + in_raid, + &active, + objectives, + item, + crate::items::item_count(ctx, killer_guid, item), + ) } /// Purge every `game_corpse_loot` / `game_corpse_loot_eligible` row still parked on `guid`, BEFORE a @@ -1107,40 +1067,6 @@ mod tests { // ---- LOOT-FAMILY COMPLETENESS ---- - /// `needs_item_pure` — the pure decision behind `killer_needs_item`: a COLLECT_ITEM objective on an - /// ACTIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item - /// (wrong target), or an objective whose quest ISN'T in `active_quests` (a stale/rewarded quest's - /// leftover objective row) all say no. - #[test] - fn needs_item_pure_matches_only_an_active_quests_collect_item_objective() { - let active = [100u32, 200]; - let objectives = [ - (100u32, objective_kind::COLLECT_ITEM, 55u32, 10u32), // matches — quest 100 is active, needs 10 - (100, objective_kind::KILL_CREATURE, 999, 5), // wrong kind (same quest) - (300, objective_kind::COLLECT_ITEM, 55, 10), // right item, but quest 300 is NOT active - ]; - assert!(needs_item_pure(&active, &objectives, 55, 0)); - assert!( - !needs_item_pure(&active, &objectives, 999, 0), - "a KILL_CREATURE objective never satisfies an item need" - ); - assert!( - !needs_item_pure(&active, &objectives, 12345, 0), - "no objective names this item at all" - ); - assert!( - !needs_item_pure(&[], &objectives, 55, 0), - "no active quests at all → nothing is ever needed" - ); - // The count cap: holding 9/10 still needs it; 10/10 (or more) stops the drop. - assert!(needs_item_pure(&active, &objectives, 55, 9)); - assert!( - !needs_item_pure(&active, &objectives, 55, 10), - "requirement met → the quest drop stops" - ); - assert!(!needs_item_pure(&active, &objectives, 55, 11)); - } - #[test] fn quest_take_allowed_admits_the_reservee_unconditionally_and_needing_takers_on_shared_rows() { // Unreserved (shared) row: any needing taker is admitted; a non-needing one is rejected. diff --git a/module/src/quest.rs b/module/src/quest.rs index 12716ada1..b2a576379 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -39,23 +39,7 @@ use crate::reputation::game_player_reputation; #[cfg(feature = "debug_reducers")] use crate::{game_creature_spawn, game_creature_template}; -/// Objective kinds (`QuestObjective.kind`). Only KILL ships today; the others are the documented -/// extension points the schema already accommodates (a new kind needs only a new call site into -/// [`credit_objective`], no migration). -pub mod objective_kind { - /// Kill `required_count` of creature `target_entry`. [kill] - pub const KILL_CREATURE: u8 = 0; - /// Hold `required_count` of ITEM `target_entry` — completion is a LIVE inventory count (no kill - /// counter), consumed on turn-in. Covers cmangos quest ReqItemId/ReqItemCount. [collect] - pub const COLLECT_ITEM: u8 = 1; - /// Use `required_count` of GAMEOBJECT `target_entry` (a lever/totem/quest-object). Credited by - /// [`on_gameobject_used`] from the CMSG_GAMEOBJ_USE path, mirroring KILL_CREATURE. [use] - pub const USE_GAMEOBJECT: u8 = 2; - /// Enter AreaTrigger `target_entry` (a zone the client detects, e.g. a mine for an "explore" quest). - /// Credited by [`on_areatrigger_entered`] from the CMSG_AREATRIGGER path, mirroring USE_GAMEOBJECT. - /// `required_count` is 1 (a single explore). [explore] - pub const EXPLORE_AREATRIGGER: u8 = 3; -} +pub use lyracore_shared::quest::objective_kind; /// The raid quest-credit rule. Shared with the Gateway (`gateway/src/stdb/reads/items.rs`'s loot /// window must apply the identical rule, or it promises a Raid member an item their `take_loot` @@ -294,7 +278,7 @@ pub struct QuestObjective { } /// A quest that needs its source event before ordinary objectives can complete it. Kept separately -/// from [`QuestTemplate`] because this is Module-only completion metadata. +/// from [`QuestTemplate`] so completion requirements do not change the template schema. #[table( accessor = game_quest_event_requirement, index(accessor = by_quest, btree(columns = [quest_entry])) @@ -494,37 +478,10 @@ crate::character_owned!(transfer, fn sweep_transfer_game_character_quest_event_c /// sized to the quest's objective count (≤ this). Objectives past this are ignored at accept time. pub const MAX_OBJECTIVES: usize = 4; -/// Is `cq` complete — has every objective of its quest reached its `required_count`? Joins the quest's -/// [`QuestObjective`] rows and compares each against `counts[obj_index]` (a missing/short count = not -/// met). A quest with NO objectives is trivially complete (a pure talk-to-giver quest). -/// -/// A seam a Package reads: `apply_turn_in_quest` stays the authority that answers it for real, and a -/// Package asks the same question before it sends a Character walking to the ender. It answers -/// objectives ONLY — it does not read `deadline_micros`, so an expired quest reads complete here and -/// is still refused at turn-in; a caller that cares about the deadline checks it itself. +/// Completion requires ordinary objectives and any event requirement. Collect objectives read +/// carried items; other objectives read their progress count. This does not check the deadline; +/// turn-in remains the authority for expired quests. pub(crate) fn quest_is_complete(ctx: &ReducerContext, cq: &CharacterQuest) -> bool { - let has_event_credit = ctx - .db - .game_character_quest_event_credit() - .by_character() - .filter(&cq.character_guid) - .any(|credit| credit.quest_entry == cq.quest_entry); - let ordinary_objectives_complete = ctx - .db - .game_quest_objective() - .by_quest() - .filter(&cq.quest_entry) - .all(|obj| { - // KILL objectives read the per-objective kill counter; COLLECT objectives read a LIVE - // inventory count of the required item — no counter, completion follows the bag. - let have = match obj.kind { - objective_kind::COLLECT_ITEM => { - crate::items::item_count(ctx, cq.character_guid, obj.target_entry) - } - _ => cq.counts.get(obj.obj_index as usize).copied().unwrap_or(0), - }; - have >= obj.required_count - }); let requires_event_credit = ctx .db .game_quest_event_requirement() @@ -532,7 +489,28 @@ pub(crate) fn quest_is_complete(ctx: &ReducerContext, cq: &CharacterQuest) -> bo .filter(&cq.quest_entry) .next() .is_some(); - ordinary_objectives_complete && (!requires_event_credit || has_event_credit) + let has_event_credit = ctx + .db + .game_character_quest_event_credit() + .by_character() + .filter(&cq.character_guid) + .any(|credit| credit.quest_entry == cq.quest_entry); + lyracore_shared::quest::objectives_complete( + ctx.db + .game_quest_objective() + .by_quest() + .filter(&cq.quest_entry) + .map(|obj| lyracore_shared::quest::Objective { + kind: obj.kind, + index: obj.obj_index, + target_entry: obj.target_entry, + required_count: obj.required_count, + }), + &cq.counts, + |entry| crate::items::item_count(ctx, cq.character_guid, entry), + requires_event_credit, + has_event_credit, + ) } /// Does creature `creature_entry` have the `role` relation to `quest_entry` (i.e. is it a valid giver @@ -788,49 +766,37 @@ fn giver_has_quest_role( /// giver to name. Bag room is not here either — the accept EFFECTS grant a `src_item` after every /// Gate has passed, and that Refusal is `crate::items`'. /// -/// Callers: [`apply_accept_quest`] (the authority), [`evaluate_share`]'s party-share preview, and a -/// Package choosing a quest before it walks a Character to the giver. The Gateway keeps a fourth -/// copy in `eval_relation` (`gateway/src/stdb/reads/quest.rs`) for the `!` icon: it answers the same -/// question over its own subscription-cache row types and cannot call Module code, so -/// `lyracore_shared::quest`'s two mask functions are all the tiers share. A Gate changed here must -/// visit that predicate too, or the icon offers a quest this reducer refuses. +/// The Module and Gateway apply the shared acceptance rules to their own row facts. pub(crate) fn accept_gates( ctx: &ReducerContext, player: &crate::WorldEntity, tmpl: &QuestTemplate, ) -> Result, String> { - if player.level < tmpl.min_level { - return Err(format!("requires level {}", tmpl.min_level)); - } - // Race/class gates (cmangos bitmasks; 0 = all). Race/class live packed in unit_bytes_0 - // (race | class<<8 | gender<<16 | power<<24) — the same source the XP curve reads. - if !lyracore_shared::quest::race_allowed(tmpl.required_races, player.race()) { - return Err("quest not available to your race".to_string()); - } - if !lyracore_shared::quest::class_allowed(tmpl.required_classes, player.class()) { - return Err("quest not available to your class".to_string()); - } - // Prerequisite chain: the previous quest in the chain must be turned in (rewarded) first. This is - // what stops a fresh character from accepting a mid-chain quest (the `!`-over-everything bug). - if tmpl.prev_quest_id != 0 && !quest_is_rewarded(ctx, player.guid, tmpl.prev_quest_id) { - return Err("must complete the prerequisite quest first".to_string()); - } - // A repeatable quest already turned in (rewarded=true) is RESET in place rather than rejected as a - // duplicate — this is the ONLY way a repeatable quest is ever re-taken. The row is UPDATED, never - // deleted, so a re-accept only ever flips `rewarded` back to false for the DURATION of the new attempt - // (exactly like any other active quest); it never erases the fact the quest was completed before, and - // `apply_turn_in_quest` always marks it rewarded again on the next turn-in. Any other existing row - // (still active, or a non-repeatable already-rewarded row) is a hard duplicate. - // (timed quests): an existing FAILED row (expired past its deadline) is re-acceptable - // exactly like a repeatable-rewarded one — reset in place, never a hard duplicate. This is - // independent of `repeatable`: ANY timed quest can be retried after it fails, not just repeatable - // ones (vanilla: a failed escort/timed quest can always be picked up again). + use lyracore_shared::quest::{acceptance, AcceptanceRefusal, AcceptanceRequirements, Attempt}; let existing = character_quest_row(ctx, player.guid, tmpl.entry); - if let Some(ref row) = existing { - if !(row.failed || (tmpl.repeatable && row.rewarded)) { - return Err("already on or completed that quest".to_string()); - } - } + acceptance( + AcceptanceRequirements { + min_level: tmpl.min_level, + required_races: tmpl.required_races, + required_classes: tmpl.required_classes, + repeatable: tmpl.repeatable, + }, + player.level, + player.race(), + player.class(), + tmpl.prev_quest_id == 0 || quest_is_rewarded(ctx, player.guid, tmpl.prev_quest_id), + existing.as_ref().map(|row| Attempt { + rewarded: row.rewarded, + 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(), + })?; Ok(existing) } @@ -1152,17 +1118,11 @@ pub(crate) fn request_turn_in_quest( player.money = player.money.saturating_add(tmpl.reward_money); // Quest XP is scaled by the realm xp_rate at the source (like kill XP), so a custom-rate realm // speeds up quest leveling too. Quest XP has no rested bonus, so this is a plain scale. - // Quest XP source, in priority: an explicit reward_xp override → authentic cmangos - // RewMoneyMaxLevel/0.6 (once a re-import populates reward_money_max_level) → the xp_reward - // placeholder for un-reimported rows. The gateway preview (reads.rs quest_detail_view) resolves the - // SAME three-way so the completion popup equals this grant. - let base_xp = if tmpl.reward_xp > 0 { - tmpl.reward_xp - } else if tmpl.reward_money_max_level > 0 { - lyracore_shared::quest::quest_xp(tmpl.reward_money_max_level) - } else { - lyracore_shared::quest::xp_reward(tmpl.quest_level) - }; + let base_xp = lyracore_shared::quest::base_reward_xp( + tmpl.reward_xp, + tmpl.reward_money_max_level, + tmpl.quest_level, + ); let xp = crate::xp::rated_xp(ctx, base_xp); // Level-cap payout: at the vanilla level cap, quest XP is USELESS (no next // level to fill) — real vanilla instead converts it to copper. Money-converted, never granted as diff --git a/module/src/reputation.rs b/module/src/reputation.rs index c84f28ce0..951adb32f 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -81,16 +81,7 @@ const REP_MAX: i32 = 42000; /// Thresholds are the raw lower bounds mangos uses. Pure — unit-tested. `pub(crate)` so vendor pricing /// can read it. Neutral is the baseline (an unknown faction → 0 standing → Neutral). pub(crate) fn reputation_rank(standing: i32) -> u8 { - match standing { - s if s >= 42000 => 7, // Exalted - s if s >= 21000 => 6, // Revered - s if s >= 9000 => 5, // Honored - s if s >= 3000 => 4, // Friendly - s if s >= 0 => 3, // Neutral - s if s >= -3000 => 2, // Unfriendly - s if s >= -6000 => 1, // Hostile - _ => 0, // Hated - } + lyracore_shared::faction::reputation_rank(standing) } /// Vendor buy-price discount PERCENT for a raw standing. Vanilla `GetReputationPriceDiscount`: @@ -148,26 +139,25 @@ pub(crate) fn npc_refuses_interaction( let Some(npc_ft) = templates.id().find(npc.faction_template) else { return false; }; - if let Some(parent) = ctx.db.game_faction().faction_id().find(npc_ft.faction) { - if parent.reputation_index >= 0 { - let row = ctx - .db + let reputation = ctx + .db + .game_faction() + .faction_id() + .find(npc_ft.faction) + .filter(|parent| parent.reputation_index >= 0) + .and_then(|parent| { + ctx.db .game_player_reputation() .by_character() .filter(&player.guid) - .find(|row| row.faction_id == parent.faction_id); - if let Some(row) = row { - if row.at_war { - return true; - } - return reputation_rank(row.standing) <= 2; - } - } - } - templates + .find(|row| row.faction_id == parent.faction_id) + }) + .map(|row| (row.standing, row.at_war)); + let template_hostile = templates .id() .find(player.faction_template) - .is_some_and(|player_ft| crate::faction::compute_hostile(&npc_ft, &player_ft)) + .is_some_and(|player_ft| crate::faction::compute_hostile(&npc_ft, &player_ft)); + lyracore_shared::faction::refuses_interaction(reputation, template_hostile) } /// Add `amount` reputation (may be negative) for `player_guid` with `faction_id`, clamped to diff --git a/module/src/skill.rs b/module/src/skill.rs index b6e5134b0..201a925d2 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -512,22 +512,7 @@ pub(crate) fn gain_defense_skill(ctx: &ReducerContext, target: &WorldEntity) { /// forks its profession-learn branch on this same taxonomy — a weapon-master offering (`learn_skill_line` set /// to a weapon line) needs a LEVEL-derived cap, not the offering's static tier column. pub(crate) fn is_combat_skill_line(line: u32) -> bool { - use skill_line::*; - matches!( - line, - AXE_1H - | AXE_2H - | MACE_1H - | MACE_2H - | POLEARM - | SWORD_1H - | SWORD_2H - | STAFF - | FIST - | DAGGER - | UNARMED - | DEFENSE - ) + lyracore_shared::trainer::is_combat_skill_line(line) } /// Is `line` the Riding line? Its own third taxonomy alongside `is_combat_skill_line` and diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 46948f4e8..3d59a7f11 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -749,78 +749,55 @@ pub(crate) fn apply_effect( // never start a proc. triggered: bool, ) -> EffectHit { - if e.kind & KIND_AURA_BIT != 0 { - // CC IMMUNITY: an `A_CONTROL` crowd-control effect (a stun/root/…) is REFUSED if the target is - // immune to that mechanic (an active `A_IMMUNITY(mechanic)` aura). The effect simply doesn't land - // — no aura is placed — so the unit is never stunned/rooted while the immunity holds. Gated per - // EFFECT (not per cast), so a multi-effect spell still applies its non-CC effects. Baseline-safe: - // `is_immune_to_mechanic` is `false` for any unit without an `A_IMMUNITY` aura → unchanged. - if e.kind == A_CONTROL - && e.p0_kind == P_MECHANIC - && is_immune_to_mechanic(ctx, target_guid, e.p0) - { - log::info!( - "spell {} A_CONTROL(mechanic {}) refused — target {target_guid} is immune", - e.spell_id, - e.p0 + use lyracore_shared::spell::taxonomy::EffectKind; + + let kind = match EffectKind::try_from(e.kind) { + Ok(kind) => kind, + // Authored high-bit codes have always produced inert auras when no reader handles them. + Err(raw) if raw & KIND_AURA_BIT != 0 => { + return apply_aura_effect( + ctx, + e, + hdr, + caster_guid, + target_guid, + cast_target_guid, + level, + points, ); - return EffectHit::none(); // a refused CC aura deals no damage - } - // All aura kinds are PLACED identically (the difference is how tick_auras / combat READ the - // frozen row). A_PERIODIC_DAMAGE/HEAL (ticked), A_MOD_COMBAT (combat-read), A_FLAG (passive - // marker) are wired; an aura kind with no tick/read handler is simply an inert marker for now. - aura_apply( - ctx, - e, - hdr, - caster_guid, - target_guid, - cast_target_guid, - level, - points, - ); - // Linked-debuff APPLY (Weakened Soul): the twin of the refusal gate in `resolve_cast_at` — a - // nonzero `e.p1` names a SEPARATE spell to also apply on `target_guid` right after this aura lands - // (PW:Shield's absorb effect names Weakened Soul). Generic over the kind/spell id: any aura effect - // can opt in via its `p1`, the engine never names one. No-op if the linked spell/effect isn't loaded - // (data-missing → graceful skip, never panics) or `p1 == 0` (every existing aura effect). - if e.p1 != 0 { - apply_linked_debuff(ctx, e.p1 as u32, caster_guid, target_guid, level); } - // CC interrupt: an action-blocking control (stun/poly/fear — NOT root, which still allows - // casting) landing on a unit that is MID-CAST cancels its in-progress cast AND any channel it is - // running (Arcane Missiles stops if you're stunned/feared). Checked AFTER the aura lands so - // `is_action_blocked` sees it. Routes through `break_channel` (which both interrupts the timed cast - // and deletes the channel aura) so CC tears down a channel for free — without it the deferred cast - // would still fire if the CC expired before the cast finished, and a channel would keep ticking - // (the tick arm's own action-blocked check would END it next tick, but breaking it now is prompt). - // No-op when the new aura isn't action-blocking, or the target has no cast/channel. [entity] - if e.kind == A_CONTROL && is_action_blocked(ctx, target_guid) { - break_channel(ctx, target_guid); + Err(raw) => { + log::info!( + "spell {} effect kind 0x{raw:02x} not implemented (graceful no-op)", + e.spell_id + ); + return EffectHit::none(); } - return EffectHit::none(); // an aura effect deals no direct cast damage - } - match e.kind { - E_SUMMON_HOSTILE => { + }; + match kind { + EffectKind::SummonHostile => { if let Err(error) = crate::creatures::summon_hostile(ctx, caster_guid, hdr, e) { spacetimedb::log::warn!("hostile summon refused: {error}"); } EffectHit::none() } - E_DISTRACT => { + EffectKind::Distract => { // The ground destination Gate refuses a Distract without a point before it gets here. if let Some(point) = dest { crate::creatures::distraction::distract(ctx, target_guid, point, points); } EffectHit::none() } - E_DUEL => { + EffectKind::Duel => { if e.p0_kind == P_GAMEOBJECT_ENTRY { crate::duel::request_duel(ctx, caster_guid, target_guid, e.p0.max(0) as u32); } EffectHit::none() } - E_DAMAGE | E_WEAPON_STRIKE | E_JUDGEMENT | E_FINISHER_DAMAGE => apply_damage_effect( + EffectKind::Damage + | EffectKind::WeaponStrike + | EffectKind::Judgement + | EffectKind::FinisherDamage => apply_damage_effect( ctx, e, hdr, @@ -830,24 +807,38 @@ pub(crate) fn apply_effect( points, triggered, ), - E_HEAL | E_HEAL_MAX_HEALTH | E_ENERGIZE | E_CONVERT_RESOURCE | E_POWER_BURN - | E_ADD_COMBO => { + EffectKind::Heal + | EffectKind::HealMaxHealth + | EffectKind::Energize + | EffectKind::ConvertResource + | EffectKind::PowerBurn + | EffectKind::AddCombo => { apply_restore_effect(ctx, e, hdr, caster_guid, target_guid, points, triggered) } - E_TAUNT | E_INTERRUPT | E_REDUCE_THREAT | E_NEXT_SWING => { + EffectKind::Taunt + | EffectKind::Interrupt + | EffectKind::ReduceThreat + | EffectKind::NextSwing => { apply_threat_effect(ctx, e, hdr, caster_guid, target_guid, points) } - E_CHARGE | E_BLINK | E_RECALL_HOME | E_DISMOUNT | E_PERSISTENT_AREA => { + EffectKind::Charge + | EffectKind::Blink + | EffectKind::RecallHome + | EffectKind::Dismount + | EffectKind::PersistentArea => { apply_reposition_effect(ctx, e, hdr, caster_guid, target_guid, points, dest) } - E_CREATE_ITEM | E_PICKPOCKET => { + EffectKind::CreateItem | EffectKind::Pickpocket => { apply_inventory_effect(ctx, e, caster_guid, target_guid, points) } - E_RESURRECT | E_SUMMON_PET | E_TAME_CREATURE | E_SET_STANCE => { - apply_character_effect(ctx, e, caster_guid, target_guid, points) + EffectKind::Resurrect + | EffectKind::SummonPet + | EffectKind::TameCreature + | EffectKind::SetStance => apply_character_effect(ctx, e, caster_guid, target_guid, points), + EffectKind::Trigger | EffectKind::Scripted => { + apply_delegating_effect(ctx, e, caster_guid, target_guid, level) } - E_TRIGGER | E_SCRIPTED => apply_delegating_effect(ctx, e, caster_guid, target_guid, level), - E_SELF_RESURRECT => { + EffectKind::SelfResurrect => { // Only `do_self_resurrect` reads this effect; the cast pipeline refuses dead casters. log::info!( "spell {} self-resurrect effect has no cast path (no-op)", @@ -855,7 +846,7 @@ pub(crate) fn apply_effect( ); EffectHit::none() } - E_DISPEL => { + EffectKind::Dispel => { // The dispelled CATEGORY: the effect's `p0` (the authored override) if set, else the // dispelling spell's header `dispel_type`. 0 = strip every foreign aura (the baseline — // Dispel Magic 527 has p0=0 + dispel_type=0, so it strips everything). @@ -868,19 +859,117 @@ pub(crate) fn apply_effect( log::info!("dispel (cat {category}) on {target_guid} removed {removed} debuff(s)"); EffectHit::none() } - other => { - // Unmapped residue: a graceful no-op + log (the import safety net — casts and does - // nothing, vs doing the wrong thing). Spells that LOOK scripted in the raw DBC but are - // really a known effect (Holy Light → a heal, Life Tap → a resource convert, Charge → a rush) - // are reclassified to a generic kind at IMPORT time (the importer's curated correction), so - // they arrive here as E_HEAL / E_CONVERT_RESOURCE / E_CHARGE and never reach this arm. + EffectKind::EnchantItem + | EffectKind::Disenchant + | EffectKind::Fish + | EffectKind::OpenLock + | EffectKind::FeedPet => { log::info!( - "spell {} effect kind 0x{other:02x} not implemented (graceful no-op)", - e.spell_id + "spell {} effect kind 0x{:02x} has no cast path (no-op)", + e.spell_id, + e.kind ); EffectHit::none() } + EffectKind::AuraPeriodicDamage + | EffectKind::AuraPeriodicHeal + | EffectKind::AuraPeriodicEnergize + | EffectKind::AuraPeriodicTrigger + | EffectKind::AuraModStat + | EffectKind::AuraModResistance + | EffectKind::AuraAbsorb + | EffectKind::AuraModCombat + | EffectKind::AuraModSpeed + | EffectKind::AuraModHealthPower + | EffectKind::AuraModDamageTaken + | EffectKind::AuraSeal + | EffectKind::AuraStealth + | EffectKind::AuraCombatHealthRegenPct + | EffectKind::AuraModStatPct + | EffectKind::AuraProcTrigger + | EffectKind::AuraSpellmodFlat + | EffectKind::AuraSpellmodPct + | EffectKind::AuraDisarm + | EffectKind::AuraRetaliate + | EffectKind::AuraControl + | EffectKind::AuraImmunity + | EffectKind::AuraModDetectRange + | EffectKind::AuraMounted + | EffectKind::AuraProcDamage + | EffectKind::AuraSelfResurrect + | EffectKind::AuraFlag => apply_aura_effect( + ctx, + e, + hdr, + caster_guid, + target_guid, + cast_target_guid, + level, + points, + ), + } +} + +#[allow(clippy::too_many_arguments)] +fn apply_aura_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + cast_target_guid: u64, + level: u8, + points: i32, +) -> EffectHit { + // CC IMMUNITY: an `A_CONTROL` crowd-control effect (a stun/root/…) is REFUSED if the target is + // immune to that mechanic (an active `A_IMMUNITY(mechanic)` aura). The effect simply doesn't land + // — no aura is placed — so the unit is never stunned/rooted while the immunity holds. Gated per + // EFFECT (not per cast), so a multi-effect spell still applies its non-CC effects. Baseline-safe: + // `is_immune_to_mechanic` is `false` for any unit without an `A_IMMUNITY` aura → unchanged. + if e.kind == A_CONTROL + && e.p0_kind == P_MECHANIC + && is_immune_to_mechanic(ctx, target_guid, e.p0) + { + log::info!( + "spell {} A_CONTROL(mechanic {}) refused — target {target_guid} is immune", + e.spell_id, + e.p0 + ); + return EffectHit::none(); // a refused CC aura deals no damage + } + // All aura kinds are PLACED identically (the difference is how tick_auras / combat READ the + // frozen row). A_PERIODIC_DAMAGE/HEAL (ticked), A_MOD_COMBAT (combat-read), A_FLAG (passive + // marker) are wired; an aura kind with no tick/read handler is simply an inert marker for now. + aura_apply( + ctx, + e, + hdr, + caster_guid, + target_guid, + cast_target_guid, + level, + points, + ); + // Linked-debuff APPLY (Weakened Soul): the twin of the refusal gate in `resolve_cast_at` — a + // nonzero `e.p1` names a SEPARATE spell to also apply on `target_guid` right after this aura lands + // (PW:Shield's absorb effect names Weakened Soul). Generic over the kind/spell id: any aura effect + // can opt in via its `p1`, the engine never names one. No-op if the linked spell/effect isn't loaded + // (data-missing → graceful skip, never panics) or `p1 == 0` (every existing aura effect). + if e.p1 != 0 { + apply_linked_debuff(ctx, e.p1 as u32, caster_guid, target_guid, level); + } + // CC interrupt: an action-blocking control (stun/poly/fear — NOT root, which still allows + // casting) landing on a unit that is MID-CAST cancels its in-progress cast AND any channel it is + // running (Arcane Missiles stops if you're stunned/feared). Checked AFTER the aura lands so + // `is_action_blocked` sees it. Routes through `break_channel` (which both interrupts the timed cast + // and deletes the channel aura) so CC tears down a channel for free — without it the deferred cast + // would still fire if the CC expired before the cast finished, and a channel would keep ticking + // (the tick arm's own action-blocked check would END it next tick, but breaking it now is prompt). + // No-op when the new aura isn't action-blocking, or the target has no cast/channel. [entity] + if e.kind == A_CONTROL && is_action_blocked(ctx, target_guid) { + break_channel(ctx, target_guid); } + EffectHit::none() } /// The hit a damage arm hands to the shared pipeline: this spell, and the proc event its source diff --git a/module/src/spell/math.rs b/module/src/spell/math.rs index 475da0e62..b713fe403 100644 --- a/module/src/spell/math.rs +++ b/module/src/spell/math.rs @@ -610,9 +610,15 @@ pub(crate) fn stance_threat_pct(stance: u8) -> i32 { /// `effective_swing_time` folds `SPEED_SWING` (Thunder Clap's slow, the migrated Quickening haste) and /// `effective_move_speed` folds `SPEED_MOVE` (Hamstring's snare). 0 for an un-affected unit. [entity] pub(crate) fn speed_bonus(ctx: &ReducerContext, unit_guid: u64, speed_kind: u8) -> i32 { - sum_active_auras(ctx, unit_guid, |a| { - a.eff_kind == A_MOD_SPEED && a.eff_p0 == speed_kind as i32 - }) + lyracore_shared::aura::speed_bonus( + auras_on(ctx, unit_guid).map(|a| lyracore_shared::aura::AuraAmount { + kind: a.eff_kind, + parameter: a.eff_p0, + amount: a.amount, + stacks: a.stacks, + }), + speed_kind, + ) } /// The owner's active auras as an iterator — the READ twin of `sum_active_auras`, scanning the IDENTICAL @@ -629,7 +635,7 @@ pub(crate) fn auras_on(ctx: &ReducerContext, unit_guid: u64) -> impl Iterator i32 { - a.amount * (a.stacks.max(1) as i32) + lyracore_shared::aura::stacked_amount(a.amount, a.stacks) } /// Sum `amount × stacks` over every active aura on `unit_guid` matching `pred` — the shared pipeline behind @@ -725,13 +731,18 @@ pub(crate) fn stat_pct_bonus(ctx: &ReducerContext, unit_guid: u64, stat: u8) -> /// effective resistance before computing damage mitigation. The matching equipped resistance Stat Kind /// is added once; a multi-school spell uses its lowest school bit, matching the mitigation caller. [entity] pub(crate) fn resistance_bonus(ctx: &ReducerContext, unit_guid: u64, school_mask: u8) -> i32 { - let aura = sum_active_auras(ctx, unit_guid, |a| { - a.eff_kind == A_MOD_RESISTANCE && (a.eff_p0 as u32 & school_mask as u32) != 0 - }); - let gear = crate::items::EquipStat::resistance_for_school(school_mask) - .map(|which| crate::items::equipped_stat_bonus(ctx, unit_guid, which)) - .unwrap_or(0); - aura.saturating_add(gear) + use lyracore_shared::aura::AuraAmount; + lyracore_shared::resistance::resistance_bonus( + school_mask, + auras_on(ctx, unit_guid).map(|a| AuraAmount { + kind: a.eff_kind, + parameter: a.eff_p0, + amount: a.amount, + stacks: a.stacks, + }), + crate::items::resistance_items(ctx, unit_guid), + |id, kind| crate::items::enchant_stat(ctx, id, kind), + ) } // =========================================================================================== @@ -782,16 +793,7 @@ pub(crate) fn aura_moves_vitals(kind: u8, p0: i32) -> bool { /// the two stats that move a pool) — a +STR/+AGI/+SPI buff, an AP buff, or a crit-rating buff moves the /// sheet even though none of them move a pool. Pure → unit-tested (mirrors `aura_moves_vitals`'s test /// shape). -pub(crate) fn aura_moves_sheet(kind: u8, p0: i32) -> bool { - (kind == A_MOD_STAT - && (p0 == STAT_STR as i32 - || p0 == STAT_AGI as i32 - || p0 == STAT_STA as i32 - || p0 == STAT_INT as i32 - || p0 == STAT_SPI as i32 - || p0 == STAT_ALL as i32)) - || (kind == A_MOD_COMBAT && (p0 == COMBAT_ATTACK_POWER as i32 || p0 == COMBAT_CRIT as i32)) -} +pub(crate) use lyracore_shared::aura::moves_sheet as aura_moves_sheet; /// One shield's draw against `incoming` damage from a pool of `remaining`: returns /// `(absorbed, pool_left, leftover)`. The shield soaks up to its pool — `absorbed = min(remaining, diff --git a/module/src/spell/mod.rs b/module/src/spell/mod.rs index 829768f47..9dc34a265 100644 --- a/module/src/spell/mod.rs +++ b/module/src/spell/mod.rs @@ -113,37 +113,37 @@ pub use tables::{ SpellCooldown, SpellEffect, SpellImpactEvent, SpellProcEvent, SpellReagent, CAST_FAIL_NO_POWER, }; -pub(crate) use tables::SpellCastEventKind; -#[cfg(test)] -pub(crate) use taxonomy::{ - ALL_AURA_KINDS, ALL_INSTANT_KINDS, P_COMBAT_FIELD, P_ENCHANT_ID, P_FLAG, P_FLAT_MANA, - P_ITEM_ENTRY, P_NONE, P_POWER_TYPE, P_RAW, P_SCHOOL_MASK, P_SPELL_ID, P_STAT_ID, STANCE_BATTLE, - STANCE_BERSERKER, T_SCRIPTED, -}; -pub(crate) use taxonomy::{ +pub(crate) use lyracore_shared::spell::taxonomy::{ A_ABSORB, A_COMBAT_HEALTH_REGEN_PCT, A_CONTROL, A_DISARM, A_FLAG, A_IMMUNITY, A_MOD_COMBAT, A_MOD_DAMAGE_TAKEN, A_MOD_DETECT_RANGE, A_MOD_RESISTANCE, A_MOD_SPEED, A_MOD_STAT, A_MOD_STAT_PCT, A_MOUNTED, A_PERIODIC_DAMAGE, A_PERIODIC_ENERGIZE, A_PERIODIC_HEAL, A_PERIODIC_TRIGGER, A_PROC_DAMAGE, A_PROC_TRIGGER, A_RETALIATE, A_SEAL, A_SELF_RESURRECT, A_SPELLMOD_FLAT, A_SPELLMOD_PCT, A_STEALTH, BEAST_TYPE, COMBAT_ATTACK_POWER, COMBAT_BLOCK, COMBAT_CRIT, COMBAT_DEFENSE, COMBAT_DMG_DONE, COMBAT_DODGE, COMBAT_HIT, COMBAT_PARRY, - COMBAT_SPELL_POWER, COMBAT_THREAT, CRITTER_TYPE, DISARM_SWING_RETAINED_PCT, E_ADD_COMBO, - E_BLINK, E_CHARGE, E_CONVERT_RESOURCE, E_CREATE_ITEM, E_DAMAGE, E_DISMOUNT, E_DISPEL, - E_DISTRACT, E_DUEL, E_ENERGIZE, E_FEED_PET, E_FINISHER_DAMAGE, E_HEAL, E_HEAL_MAX_HEALTH, - E_INTERRUPT, E_JUDGEMENT, E_NEXT_SWING, E_PERSISTENT_AREA, E_PICKPOCKET, E_POWER_BURN, - E_RECALL_HOME, E_REDUCE_THREAT, E_RESURRECT, E_SCRIPTED, E_SELF_RESURRECT, E_SET_STANCE, - E_SUMMON_HOSTILE, E_SUMMON_PET, E_TAME_CREATURE, E_TAUNT, E_TRIGGER, E_WEAPON_STRIKE, - HUMANOID_TYPE, INT_PER_SPELL_POWER, INT_SPELL_POWER_BASE, KIND_AURA_BIT, M_FEAR, M_POLY, - M_ROOT, M_STUN, PROJECTILE_SPEED_YPS, P_DISPLAY_ID, P_ENTRY, P_GAMEOBJECT_ENTRY, P_MECHANIC, - P_PCT_MAX_POWER, P_SPEED_KIND, RESIST_ARMOR, SPEED_MOUNTED, SPEED_MOVE, SPEED_SWING, - SPELLMOD_OP_CASTING_TIME, SPELLMOD_OP_DAMAGE, SPELL_ATTR_CHANNELED, - SPELL_ATTR_FINISHER_DURATION, SPELL_ATTR_INCAP_OPENER, SPELL_ATTR_PASSIVE, - SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_DAGGER, SPELL_ATTR_REQ_OVERPOWER, SPELL_ATTR_REQ_REVENGE, - SPELL_ATTR_REQ_STEALTH, SPELL_ATTR_STEALTH_SAFE, SPELL_POWER_COEFF_PCT, STANCE_BEAR, - STANCE_CAT, STANCE_DEFENSIVE, STANCE_DEFENSIVE_DMG_DONE_PCT, STANCE_DEFENSIVE_DR_PCT, - STANCE_DEFENSIVE_THREAT_PCT, STANCE_DIRE_BEAR, STAT_AGI, STAT_ALL, STAT_INT, STAT_SPI, - STAT_STA, STAT_STR, T_AREA_ALLY, T_AREA_ENEMY, T_CHAIN_ENEMY, T_SELF, T_TARGET_ALLY, - T_TARGET_ANY, T_TARGET_ENEMY, + COMBAT_SPELL_POWER, COMBAT_THREAT, CRITTER_TYPE, E_ADD_COMBO, E_BLINK, E_CHARGE, + E_CONVERT_RESOURCE, E_CREATE_ITEM, E_DAMAGE, E_DISMOUNT, E_DISTRACT, E_DUEL, E_ENERGIZE, + E_FEED_PET, E_FINISHER_DAMAGE, E_HEAL, E_HEAL_MAX_HEALTH, E_INTERRUPT, E_JUDGEMENT, + E_NEXT_SWING, E_PERSISTENT_AREA, E_PICKPOCKET, E_POWER_BURN, E_RECALL_HOME, E_REDUCE_THREAT, + E_RESURRECT, E_SCRIPTED, E_SELF_RESURRECT, E_SET_STANCE, E_SUMMON_HOSTILE, E_SUMMON_PET, + E_TAME_CREATURE, E_TAUNT, E_TRIGGER, E_WEAPON_STRIKE, HUMANOID_TYPE, KIND_AURA_BIT, M_FEAR, + M_POLY, M_ROOT, M_STUN, P_DISPLAY_ID, P_ENTRY, P_GAMEOBJECT_ENTRY, P_MECHANIC, P_PCT_MAX_POWER, + P_SPEED_KIND, RESIST_ARMOR, SPEED_MOUNTED, SPEED_MOVE, SPEED_SWING, SPELLMOD_OP_CASTING_TIME, + SPELLMOD_OP_DAMAGE, SPELL_ATTR_CHANNELED, SPELL_ATTR_FINISHER_DURATION, + SPELL_ATTR_INCAP_OPENER, SPELL_ATTR_PASSIVE, SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_DAGGER, + SPELL_ATTR_REQ_OVERPOWER, SPELL_ATTR_REQ_REVENGE, SPELL_ATTR_REQ_STEALTH, + SPELL_ATTR_STEALTH_SAFE, STANCE_BEAR, STANCE_CAT, STANCE_DEFENSIVE, STANCE_DIRE_BEAR, STAT_AGI, + STAT_ALL, STAT_INT, STAT_SPI, STAT_STA, STAT_STR, T_AREA_ALLY, T_AREA_ENEMY, T_CHAIN_ENEMY, + T_SELF, T_TARGET_ALLY, T_TARGET_ANY, T_TARGET_ENEMY, +}; +#[cfg(test)] +pub(crate) use lyracore_shared::spell::taxonomy::{ + P_NONE, P_SCHOOL_MASK, STANCE_BATTLE, STANCE_BERSERKER, T_SCRIPTED, +}; +pub(crate) use tables::SpellCastEventKind; +pub(crate) use taxonomy::{ + DISARM_SWING_RETAINED_PCT, INT_PER_SPELL_POWER, INT_SPELL_POWER_BASE, PROJECTILE_SPEED_YPS, + SPELL_POWER_COEFF_PCT, STANCE_DEFENSIVE_DMG_DONE_PCT, STANCE_DEFENSIVE_DR_PCT, + STANCE_DEFENSIVE_THREAT_PCT, }; // `stacking` is reached explicitly via `crate::spell::stacking::` (its tables' generated // accessor traits — `game_spell_group`/`game_spell_group_rule`/`game_dr_state` — collide-free but its diff --git a/module/src/spell/spellbook.rs b/module/src/spell/spellbook.rs index 8feecc3a8..64d7f3eb3 100644 --- a/module/src/spell/spellbook.rs +++ b/module/src/spell/spellbook.rs @@ -123,7 +123,7 @@ pub(crate) fn apply_racial_passives(ctx: &ReducerContext, guid: u64, race: u8, l let Some(hdr) = ctx.db.game_spell().spell_id().find(spell_id) else { continue; }; - if hdr.attributes & crate::spell::taxonomy::SPELL_ATTR_PASSIVE != 0 { + if hdr.attributes & lyracore_shared::spell::taxonomy::SPELL_ATTR_PASSIVE != 0 { crate::spell::apply_spell_auras(ctx, spell_id, guid, level, 1); } } diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 1a4fbaba1..0c6bd6e48 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -1,493 +1,10 @@ -//! KINDS / MECHANICS / param-tag / target / combat-field / stat / resistance taxonomy + the -//! spell-power scaling tuning constants. `mod.rs` exports the constants it shares by name -//! so `crate::spell::` resolves. +//! Module-only spell scaling and combat tuning. -// =========================================================================================== -// KINDS — the deduplicated effect/aura taxonomy (dispatch key). Split by MECHANISM, folded by -// DATA. High bit `KIND_AURA_BIT` => the effect places a persistent aura (vs an instant one-shot). -// The importer maps mangos (Effect, AuraName) pairs onto these; unmapped → E_SCRIPTED no-op. -// =========================================================================================== - -/// High bit on a `kind`: this effect creates a persistent `game_aura` row (vs an instant one-shot). -pub(crate) const KIND_AURA_BIT: u8 = 0x80; - -/// `Spell.attributes` bit: a PASSIVE spell (vanilla `SPELL_ATTR_PASSIVE`). Server-enforced, never -/// player-cast or player-cancelable — talent passives set it so `cancel_aura` refuses to strip them. -pub(crate) const SPELL_ATTR_PASSIVE: u32 = 0x40; - -// --- Rogue-slice cast-gate flags (our OWN bits in the DEDICATED `Spell.cast_flags` mask — NOT the raw -// vanilla `Spell.attributes`, whose bits are densely used and would collide. The importer sets these BY -// NAME; the gates in resolve_cast_at read them — keyed on the flag, never a spell id). --- -/// The spell may only be cast while the caster is BEHIND the target (rear 180° hemisphere) — Backstab. -/// Gate: reject unless `is_behind(caster, target)`. -pub(crate) const SPELL_ATTR_REQ_BEHIND: u32 = 0x0001; -/// The spell may only be cast while the caster is STEALTHED (an opener) — Sap. -/// Gate: reject unless `is_stealthed(ctx, caster_guid)`. -pub(crate) const SPELL_ATTR_REQ_STEALTH: u32 = 0x0002; -/// Casting this spell does NOT break the caster's stealth (Sap / Pick Pocket stay stealthed — vanilla). -/// The `break_stealth` chokepoint in the cast path skips a spell carrying this bit. -pub(crate) const SPELL_ATTR_STEALTH_SAFE: u32 = 0x0004; -/// A combo-FINISHER whose aura DURATION scales with the combo points spent (Slice and Dice). At -/// aura-apply, the aura's lifetime is `finisher_duration_ms(hdr.duration_ms, combo)` read off the -/// caster's combo pool on the ENEMY cast target — then the combo is spent EXACTLY ONCE (one aura effect -/// per such spell). The aura still lands on its own target kind (SnD's haste is T_SELF → the caster); -/// only its expiry is recomputed. Gate: `aura_apply` reads this bit, never a spell id. The importer -/// sets it BY NAME (Slice and Dice). -pub(crate) const SPELL_ATTR_FINISHER_DURATION: u32 = 0x0008; -/// A Sap-shaped INCAPACITATE OPENER: in addition to REQ_STEALTH, the target must be OUT of combat AND a -/// HUMANOID (vanilla Sap). Split out of REQ_STEALTH so a non-Sap stealth opener (Garrote — usable on ANY -/// creature type, in or out of combat) carries REQ_STEALTH WITHOUT these constraints. The importer sets -/// it BY NAME (Sap only); the gate in resolve_cast_at reads this bit, never a spell id. -pub(crate) const SPELL_ATTR_INCAP_OPENER: u32 = 0x0010; -/// Overpower: castable ONLY in the ~5s window after one of the caster's melee swings was DODGED. The attack -/// table (resolve_swing) stamps `overpower_until_ms` on a HIT_DODGE; the gate rejects unless that window is -/// still open. The importer sets it BY NAME (Overpower); the gate reads this bit, never a spell id. -pub(crate) const SPELL_ATTR_REQ_OVERPOWER: u32 = 0x0020; -/// Revenge: castable ONLY in the ~5s window after the caster DODGED / PARRIED / BLOCKED an incoming swing. -/// The attack table stamps `revenge_until_ms` on a HIT_DODGE/PARRY/BLOCK against this unit; the gate rejects -/// unless that window is open. The importer sets it BY NAME (Revenge); the gate reads this bit, never an id. -pub(crate) const SPELL_ATTR_REQ_REVENGE: u32 = 0x0040; -/// A CHANNELED spell (Arcane Missiles): the cast holds the caster for the spell's `duration_ms` and TICKS a -/// per-tick effect each `period_ms` (the channel = a self-`A_PERIODIC_TRIGGER` aura, ticked by `tick_auras`). -/// The channel BREAKS early on the caster moving / starting another cast / being CC'd / the channel target -/// dying — all converging on `break_channel`. The importer sets it from the DBC `AttributesEx1` CHANNELED -/// bit (`0x44`), with a by-NAME fallback (Arcane Missiles); it ALSO drives the importer's reclassification of -/// the periodic-trigger effect (`PeriodicTriggerSpell`/`E_TRIGGER` with `period_ms>0` + a T_SELF target) into -/// `A_PERIODIC_TRIGGER`. The engine reads this bit / the kind, NEVER a spell id. -pub(crate) use lyracore_shared::spell::SPELL_ATTR_CHANNELED; -/// Backstab: castable ONLY while the caster has a DAGGER (weapon subclass 15) equipped in the MAIN-HAND -/// slot (15) — vanilla's melee-weapon-type restriction. Gate: join the caster's `game_item_instance` in -/// the main-hand slot -> `game_item_template` -> `subclass == weapon_subclass::DAGGER`; a missing/broken/ -/// non-dagger main-hand rejects. Player-only (a caster with no item rows, i.e. a creature, always fails -/// this join and is rejected too — Backstab is a rogue-only ability so this is baseline-safe in practice). -/// The importer sets it BY NAME (Backstab); the gate in resolve_cast_at reads this bit, never a spell id. -pub(crate) const SPELL_ATTR_REQ_DAGGER: u32 = 0x0100; - -/// cmangos `CREATURE_TYPE_HUMANOID` — the creature_type value the Sap incapacitate-opener requires on the -/// target. -pub(crate) const HUMANOID_TYPE: u8 = 7; - -/// cmangos `CREATURE_TYPE_BEAST` — the creature_type value a corpse must carry to be SKINNABLE (Skinning -/// profession). Imported verbatim by the importer (`ct::CREATURE_TYPE`), so Elwynn wolves/boars -/// arrive as type 1. Shared by `loot::skin_corpse`'s beast gate + the `debug_skin_nearest` nearest-search. -pub(crate) const BEAST_TYPE: u8 = 1; - -/// cmangos `CREATURE_TYPE_CRITTER` — the creature_type value that never yields XP/honor. Gates the -/// Drain Soul soul-shard grant: a critter kill mints no shard regardless of level. -pub(crate) const CRITTER_TYPE: u8 = 8; - -// --- instant effects (high bit clear) --- -pub(crate) const E_DAMAGE: u8 = 0x01; // direct damage (lethal-for-creatures via kill_creature) -pub(crate) const E_HEAL: u8 = 0x02; // direct heal (clamp to max) -pub(crate) const E_ENERGIZE: u8 = 0x03; // restore power (p0 = power type) -pub(crate) const E_DISPEL: u8 = 0x04; // strip auras (p0 = dispel category, 0 = all foreign) -pub(crate) const E_TRIGGER: u8 = 0x05; // cast trigger_spell now -pub(crate) const E_TAUNT: u8 = 0x06; // force-aggro: top the caster's threat on the target creature -pub(crate) const E_CREATE_ITEM: u8 = 0x07; // create item(s) in the caster's inventory (conjure / quest item); p0 = item entry, base_points = count -pub(crate) const E_WEAPON_STRIKE: u8 = 0x08; // physical melee ability: damage = weapon swing roll + base_points (Mortal Strike et al.) -pub(crate) const E_CHARGE: u8 = 0x09; // rush the caster into melee of the target (position change; +rage rides a separate E_ENERGIZE) -pub(crate) const E_CONVERT_RESOURCE: u8 = 0x0A; // drain the caster's health into power 1:1 (Life Tap); from/to pools generalize via params later -pub(crate) const E_JUDGEMENT: u8 = 0x0B; // unleash the caster's active SEAL: a holy hit derived from the seal, then consume it -pub(crate) const E_ADD_COMBO: u8 = 0x0C; // GENERATOR: +1 combo point on the target (Sinister Strike / Gouge / Backstab), capped at 5 — game_combo_point -pub(crate) const E_FINISHER_DAMAGE: u8 = 0x0D; // FINISHER: damage = base_points (per-point) × the target's combo points, then spend them (Eviscerate) -pub(crate) const E_RESURRECT: u8 = 0x0E; // revive a DEAD ally: restore base_points% of max hp/power + clear death/ghost (Resurrection) -pub(crate) const E_SCRIPTED: u8 = 0x0F; // script_id == 0 is the vanilla no-op; nonzero dispatches to a Runtime Script by identity (game_script.script_id) -pub(crate) const E_PICKPOCKET: u8 = 0x10; // grant the rogue a small copper pocket from a creature WITHOUT engaging (Pick Pocket) -pub(crate) const E_INTERRUPT: u8 = 0x11; // cancel the target's in-progress (timed) cast (Kick) — calls interrupt_cast; no-op if not casting. Kick ALSO locks the cast's SCHOOL for ~5s (game_school_lockout, Rogue Slice 3) -pub(crate) const E_REDUCE_THREAT: u8 = 0x12; // one-time reduction of the CASTER's CURRENT threat by base_points across all its source rows (Feint), floored at 0 — DISTINCT from Fade's A_MOD_COMBAT(COMBAT_THREAT) FUTURE-threat percent -pub(crate) const E_NEXT_SWING: u8 = 0x13; // QUEUE the strike onto the caster's next melee swing (Heroic Strike/Cleave): the cast sets next_swing_spell + charges rage (no instant damage); the next LANDED swing (resolve_swing) adds this effect's base_points as bonus damage and clears the field -pub(crate) const E_SET_STANCE: u8 = 0x14; // set the caster's stance/form to p0, per the STANCE_* convention block below (THE definition site): Warrior 0/1/2, Druid Bear/Cat/DireBear 3/4/5 — a one-shot state write to WorldEntity.stance, mutually exclusive (the old stance is overwritten, no aura cleanup). The importer reclassifies the stance/form spells' inert ModShapeshift→A_FLAG marker to this kind BY NAME, with p0 remapped from the vanilla form id via form_to_stance (the lockstep inverse of client_form_for_stance — NOT a fixed −17 offset since druid forms landed). Recasting the ACTIVE druid form toggles back to 0 (form_recast_toggles_off); a warrior same-stance recast stays a no-op. Switching automatically clears the prior stance's mitigation/threat because those are FOLDS keyed on the field, not auras -pub(crate) const E_SUMMON_PET: u8 = 0x15; // summon a persistent PET creature owned by the caster (Summon Imp): despawn any existing pet, then spawn the creature `p0` (p0_kind = P_ENTRY, a game_creature_template.entry) at the caster with owner_guid = caster. ONE pet per owner (re-summon replaces). The pet is a normal creature entity (no spawn row) that rides the behavior cycle's pet phase (follow the owner when idle / engage the owner's target in combat) + the existing chase/melee. The importer maps the raw vanilla Summon effect (56) to this kind with the misc_value (the summoned creature entry) routed into p0. Despawned on owner logout/death -pub(crate) const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay on Hands): the vanilla HealMaxHealth effect (67) heals to max regardless of base_points (its DBC base is ~0), so it canNOT route through E_HEAL (which heals `points`, i.e. 0). Sets t.health = t.max_health and credits the full heal as threat. The importer splits effect 67 out of E_HEAL onto this kind (E_HEAL stays effect 10 only). Spell-power-agnostic (a max-heal needs no scaling) -pub(crate) const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame: convert the explicit wild target into one durable Hunter identity plus a live Hunter pet -pub(crate) const E_FEED_PET: u8 = 0x21; // consume an explicit item target after Hunter-pet care validation -pub(crate) const E_ENCHANT_ITEM: u8 = 0x17; // apply a permanent enchant to the cast's ITEM target: p0 = the enchant_id (p0_kind = P_ENCHANT_ID), looked up in the imported enchantment catalogue for the stat bonus. The spell never runs through resolve_cast (it targets an item GUID, not a unit) — the GATEWAY intercepts CMSG_CAST_SPELL, sees this effect kind, resolves the item GUID→bag slot, and calls enchant_item_on_slot(slot, p0). Routing is by KIND + p0, NOT a hardcoded spell-id list, so a new enchant is a data row -pub(crate) const E_DISENCHANT: u8 = 0x18; // disenchant the cast's ITEM target into reagents: same gateway-intercept path as E_ENCHANT_ITEM, dispatched to disenchant_item(slot). No params (the module validates disenchantability + yields dust by item) -pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor -pub(crate) const E_FISH: u8 = 0x1C; // Fishing: gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. -pub(crate) const E_OPEN_LOCK: u8 = 0x1D; // Pick Lock: gateway-intercepted like E_FISH (0x1C) — CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill vs the game_lock required_skill). Inert in-module (NO resolve arm in cast.rs); exists so the gateway routes by DATA, never a spell-id list. 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. -pub(crate) const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink): a self-cast position change reusing the teleport core (like E_CHARGE), clamped to the furthest nav-LoS-clear point so it doesn't cross geometry. Root/snare removal rides a separate A_IMMUNITY effect. The importer name-rescues the dead SCRIPT teleport effect (raw 29) to this kind -/// Recall the caster to its bound home in instance 0. Item use keeps recall items. -pub(crate) const E_RECALL_HOME: u8 = 0x1F; -pub(crate) const E_DUEL: u8 = 0x22; // Duel (raw effect 83): request a server-authoritative Duel; p0 is the duel-flag gameobject template entry -/// Remove the target's active LAND MOUNT (Dazed's mount-removal half). An instant effect that calls the -/// one shared `mount::dismount` — idempotent, a silent no-op on an unmounted target, and never touched by -/// an unlanded cast (it resolves like any other instant effect). The importer translates a raw -/// `DISPEL_MECHANIC` effect whose parameter is the mount mechanic onto this kind, so the runtime never -/// implements a generic mechanic dispel and never branches on spell 1604 or a spell name. -pub(crate) const E_DISMOUNT: u8 = 0x23; -pub(crate) const E_SUMMON_HOSTILE: u8 = 0x24; // temporary ownerless summon; p0 = creature entry, p1 = required spell focus, header duration = lifetime -/// Start or refresh a Distraction on each idle enemy Creature around the ground point. The effect -/// amount is its length in seconds; the importer maps raw effect 69 here. -pub(crate) const E_DISTRACT: u8 = 0x25; -/// Self-resurrection (vanilla raw effect 94). No cast runs it: [`do_self_resurrect`] reads this row -/// from the spell a Self-Resurrection Option names. `base_points < 0` restores `-base_points` health and -/// `p0` ([`P_FLAT_MANA`]) mana. `base_points >= 0` restores that percent of max health and max mana. -/// `die_sides` is ignored, because every vanilla row carries a one-sided die that would add 1. -/// -/// [`do_self_resurrect`]: crate::spell::do_self_resurrect -pub(crate) const E_SELF_RESURRECT: u8 = 0x26; - -pub(crate) const E_POWER_BURN: u8 = 0x19; // drain N mana from the target and deal a fraction of it as damage (Mana Burn): MANA-power-type gate read off the target's `unit_bytes_0` byte 3 (same read as `is_rage_user`) — a rage/energy target is a silent no-op (power AND health untouched), matching vanilla's behaviour of skipping the effect entirely. drained = min(base_points, target.power) (floor-at-available; an empty/low pool just burns less, never fails the cast). damage = drained * p1 / 100 (p1 = the effect's ratio in basis-points — vanilla Mana Burn is EffectMultipleValue=0.5 -> p1=50 -> half the drained mana as Shadow damage); `p1<=0` (unauthored data) defaults to 100 (1:1), so a missing p1 never silently zeroes all burn damage. Dealt via the shared `apply_target_damage` (threat/kill/absorb reuse, no new wire work) - -// --- aura effects (high bit set) --- -pub(crate) const A_PERIODIC_DAMAGE: u8 = 0x90; // DoT -pub(crate) const A_PERIODIC_HEAL: u8 = 0x91; // HoT -pub(crate) const A_PERIODIC_ENERGIZE: u8 = 0x92; // periodic power - // A CHANNEL's per-tick trigger: a self-aura that, each tick, CASTS its frozen trigger spell (the channel's - // per-bolt missile — e.g. Arcane Missiles 5143 → 7268's E_DAMAGE) at the FROZEN channel target. Unlike the - // DoT/HoT/energize kinds it folds NO vitals pool of its own — it routes each tick through `resolve_cast_at`, - // so the bolt gets spell-power scaling / crit / resist / threat / the kill path FOR FREE. The trigger spell - // id is frozen in `eff_p1`; the channel target (the enemy guid) in `channel_target`. Reaped on duration like - // any aura (N ticks = duration_ms/period_ms); broken EARLY by `break_channel` (move/new-cast/CC/target-death). -pub(crate) const A_PERIODIC_TRIGGER: u8 = 0x93; // channel tick: cast the frozen trigger spell at channel_target -pub(crate) const A_MOD_STAT: u8 = 0xA0; // p0 = stat 0..4 (0xFF = all) -pub(crate) const A_MOD_RESISTANCE: u8 = 0xA1; // p0 = school mask (armor = bit0) -pub(crate) const A_ABSORB: u8 = 0xA2; // p0 = pool school. `p1` is unused by A_ABSORB itself; on ANY aura - // kind, `p1` GENERICALLY doubles as the LINKED-DEBUFF spell id (see `apply_linked_debuff`/the refusal - // gate in cast.rs); PW:Shield's own `p1` names Weakened Soul (6788). -pub(crate) const A_MOD_COMBAT: u8 = 0xA3; // p0 = combat field (AP/crit/hit/dmg/haste) -pub(crate) const A_MOD_SPEED: u8 = 0xA4; // p0 = speed kind (move/swing/cast), signed pct -pub(crate) const A_MOD_HEALTH_POWER: u8 = 0xA5; // +max hp/power -pub(crate) const A_MOD_DAMAGE_TAKEN: u8 = 0xA6; // signed % modifier to INCOMING damage (−75 = Shield Wall's 75% reduction; + = a vulnerability debuff). p0 reserved for a school mask (all-school in v1) -pub(crate) const A_SEAL: u8 = 0xA7; // proc-on-swing holy-damage seal (Seal of Righteousness): a landed melee swing reads it + adds holy; amount = per-swing seal value (weapon-speed-scaled); single active seal; consumed by E_JUDGEMENT -pub(crate) const A_STEALTH: u8 = 0xA8; // presence marker: the unit is stealthed — creatures skip it as an aggro target; removed (broken) by the caster's own swing/non-Stealth cast -/// Passive aura: X% of normal OUT-of-combat health regen continues DURING COMBAT. `amount` = the -/// percent (10 for Troll Regeneration racial). The cycle's regeneration phase sums all active -/// `A_COMBAT_HEALTH_REGEN_PCT` auras on the entity → allows `pct%` of `health_regen_per_tick` -/// during combat. 0 active auras → 0 combat regen (today's behaviour). Composed additively (two -/// such auras at 10% each grant 20%); percent is capped at 100 before the multiply. Data only — -/// no race read, no spell-id check; the same kind on any class composes identically. -pub(crate) const A_COMBAT_HEALTH_REGEN_PCT: u8 = 0xA9; -/// PERCENT stat modifier (distinct from the FLAT `A_MOD_STAT`): p0 = stat 0..4 (0xFF = all), amount = -/// percent (e.g. The Human Spirit = +5% Spirit). `recompute_vitals` folds it as a multiplier on the -/// resolved stat — `effective = (base + flat) * (100 + pct) / 100` — so it scales the base, not a flat add. -pub(crate) const A_MOD_STAT_PCT: u8 = 0xAA; -/// A Proc that starts a **Triggered Cast** of its trigger spell (vanilla `Spell.dbc` aura 42, -/// ProcTriggerSpell). `eff_p1` freezes the trigger spell id at apply, mirroring `A_PERIODIC_TRIGGER`'s -/// own `eff_p1` freeze (see `aura_apply`); the proc profile (event mask, chance, charges, internal -/// cooldown) freezes onto the same row. Sits on the CARRIER; `proc::run_proc_pass` — the one proc pass, -/// called from `combat::apply_hit` — decides whether it fires and casts the trigger spell from the -/// Carrier at the Counterparty. A carrier with no proc aura is unaffected. -pub(crate) const A_PROC_TRIGGER: u8 = 0xAB; -/// A Proc that deals its frozen `amount` as damage of the proc spell's school (vanilla `Spell.dbc` -/// aura 43, ProcTriggerDamage). Same proc profile and same one pass as [`A_PROC_TRIGGER`]; `p0` is the -/// school mask ([`P_SCHOOL_MASK`]). The firing arm is not wired yet — the pass logs and spends nothing. -pub(crate) const A_PROC_DAMAGE: u8 = 0xB4; -pub(crate) const A_SPELLMOD_FLAT: u8 = 0xAC; // spell modifier, FLAT (DBC aura 107 AddFlatModifier): p0 (P_SPELLMOD_OP) = the SpellModOp, p1 = the 32-bit affected-spell FAMILY mask (DBC EffectItemType), amount = the SIGNED flat value (Improved Fireball: op 10 cast-time, −100ms/rank). Pulled by `spell_mod` at the cast-time/damage seams — a talent passive is pure data, zero per-spell code -pub(crate) const A_SPELLMOD_PCT: u8 = 0xAD; // spell modifier, PERCENT (DBC aura 108 AddPctModifier): same shape, amount = a signed percent -/// DISARM (Warrior Disarm, DBC AuraMod 67 ModDisarm): while active on an ENEMY, the disarmed unit's melee -/// swing loses its main-hand weapon — a PLAYER drops to UNARMED damage, a CREATURE (no weapon/unarmed split) -/// keeps only a documented fraction of its swing (`DISARM_SWING_RETAINED_PCT`). A generic aura placed via the -/// KIND_AURA_BIT path (natural expiry); `control::is_disarmed` presence-reads it at the swing-range seam -/// (`combat::swing_range_ctx`), never a spell id. 0 auras → not disarmed (baseline-safe). -pub(crate) const A_DISARM: u8 = 0xAE; -/// RETALIATION (Warrior Retaliation, self-buff): while active on a unit, any REAL incoming melee hit provokes -/// ONE free main-hand counter-swing back at the attacker (`combat::retaliate_on_hit`, hooked in -/// `break_auras_on_damage`'s melee block). A generic aura placed via the KIND_AURA_BIT path; the counter-swing -/// routes through `apply_target_damage` (attacker sentinel 0) so it can never itself re-trigger retaliation — -/// no recursion. 0 auras → no counter-swing (baseline-safe). -pub(crate) const A_RETALIATE: u8 = 0xAF; -pub(crate) const A_CONTROL: u8 = 0xB0; // CC: p0 = mechanic (stun/poly/fear/root/...) -pub(crate) const A_IMMUNITY: u8 = 0xB1; // p0 = school mask OR mechanic per p0_kind -/// DETECT-RANGE modifier (Priest Mind Soothe, DBC AuraMod 91 ModDetectRange): while active on a hostile -/// CREATURE, its proximity aggro / detection radius is shifted by the aura's SIGNED `amount` YARDS — Mind -/// Soothe's amount is NEGATIVE (-10), so it SHRINKS the radius (`math::detect_range_mod` sums the active -/// auras; the behavior cycle's aggro phase ADDS the sum to the creature's aggro radius, clamped ≥ 0 — a -/// soothed mob notices the player only from closer, or not at all). A generic aura via the KIND_AURA_BIT -/// path; 0 auras → full aggro radius (baseline-safe). Read on the soothed unit. -pub(crate) const A_MOD_DETECT_RANGE: u8 = 0xB2; -/// LAND MOUNT (vanilla Mounted aura): the STATE OF RECORD for a player's ground-mounted state. `p0` is -/// the resolved creature display id (`p0_kind` = [`P_DISPLAY_ID`]), frozen at apply and projected onto -/// `WorldEntity.mount_display_id` for the client's `UNIT_FIELD_MOUNTDISPLAYID`. The projection is derived -/// from this aura set by `mount::recompute_mount`, never a second state machine — zero `A_MOUNTED` auras -/// means display 0. A normal cancelable self aura: expiry, `CMSG_CANCEL_AURA`, dispel and `E_DISMOUNT` -/// all converge on the same recompute. -pub(crate) const A_MOUNTED: u8 = 0xB3; -/// Soulstone Resurrection: while this aura is on a Character at death, its `p0` ([`P_SPELL_ID`]) becomes -/// the Character's Self-Resurrection Option. The aura itself does not survive death. -pub(crate) const A_SELF_RESURRECT: u8 = 0xB5; -pub(crate) const A_FLAG: u8 = 0xBE; // passive marker aura (no tick), p0 = flag id - -/// Documented tuning approximation for `combat::swing_range_ctx`: a DISARMED creature has no weapon/unarmed -/// damage split (creature swings roll a flat template range), so instead of zeroing its damage we retain this -/// PERCENT of its swing while disarmed — a middle-ground stand-in for "lost its weapon" that keeps the -/// mechanic meaningful without a per-creature unarmed model. Players ignore this (they fall back to their real -/// unarmed range). Tunable. pub(crate) const DISARM_SWING_RETAINED_PCT: u32 = 50; - -// --- MECHANICS (the value of `p0` for an A_CONTROL aura when `p0_kind == P_MECHANIC`; the CC kind) --- -// Our OWN taxonomy (NOT the mangos Mechanics.dbc numbering) — they need only be internally consistent -// between the seed data and the gates that read them. Small distinct values starting at 1 so 0 stays a -// reserved "no mechanic" sentinel (a frozen A_CONTROL aura whose `eff_p0` is 0 matches NO gate → inert, -// exactly the pre-CC behavior). All four crowd-control mechanics are wired to gates, split on the two -// independent axes — ACT (perform a swing/aggro/cast) and MOVE (drive own position): -// - M_STUN — cannot ACT, cannot MOVE. The full lock-down (frozen + silenced). -// - M_ROOT — CAN ACT (keeps swinging/aggroing/casting) but cannot MOVE. Snared in place. -// - M_POLY — cannot ACT, cannot MOVE (incapacitate/polymorph — frozen like stun, the action-only twin). -// - M_FEAR — cannot ACT, but is force-MOVED: the fear-flee pass walks it AWAY from the fear source each -// tick ("flees in terror"). The one mechanic that MOVES the unit rather than freezing it. -pub(crate) const M_STUN: i32 = 1; -pub(crate) const M_ROOT: i32 = 2; -pub(crate) const M_FEAR: i32 = 3; -pub(crate) const M_POLY: i32 = 4; - -// --- p0_kind: what `p0` means (single-meaning tag; import-correctness tripwire) --- -pub(crate) const P_NONE: u8 = 0; -pub(crate) const P_STAT_ID: u8 = 1; -pub(crate) const P_SCHOOL_MASK: u8 = 2; -pub(crate) const P_MECHANIC: u8 = 3; -pub(crate) const P_POWER_TYPE: u8 = 4; -pub(crate) const P_COMBAT_FIELD: u8 = 5; -pub(crate) const P_SPEED_KIND: u8 = 6; -pub(crate) const P_FLAG: u8 = 7; -pub(crate) const P_ITEM_ENTRY: u8 = 8; // p0 is a game_item_template entry (E_CREATE_ITEM) -pub(crate) const P_ENTRY: u8 = 9; // p0 is a game_creature_template entry -pub(crate) const P_ENCHANT_ID: u8 = 10; // p0 is an enchant id (E_ENCHANT_ITEM — the enchant applied to the item) - // SpellModOp values the engine consumes (mangos SpellModOp; the rest import fine and stay inert until a seam folds them). -pub(crate) const SPELLMOD_OP_DAMAGE: i32 = 0; -pub(crate) const SPELLMOD_OP_CASTING_TIME: i32 = 10; -// Slot 11 of the p0_kind taxonomy. Not read by any engine seam yet (the spell-mod fold reaches the -// op through `A_SPELLMOD_*`'s own `p0`), but the NUMBER is a stored data contract: the importer -// writes p0_kind values into `game_spell_effect`, so deleting the name would leave 11 undocumented -// and free for a future kind to collide with. -pub(crate) const P_SPELLMOD_OP: u8 = 11; // p0 is a SpellModOp (A_SPELLMOD_*: 0=damage, 7=crit, 10=cast time, 14=cost); p1 carries the 32-bit affected-spell family mask -/// The effect's `amount` is a PERCENT of the caster's MAX power, not an absolute figure (Mage Evocation's -/// A_PERIODIC_ENERGIZE restores 15% of max mana per tick). `aura_apply` reads this tag and converts the -/// stored percent to an absolute per-tick amount (`caster.max_power * amount / 100`) BEFORE freezing it onto -/// the Aura row, so the generic energize tick (`energized_value`) restores a real number. Every other p0_kind -/// leaves `amount` verbatim (baseline-safe). -pub(crate) const P_PCT_MAX_POWER: u8 = 12; -pub(crate) const P_GAMEOBJECT_ENTRY: u8 = 13; // p0 is a game_gameobject_template entry (E_DUEL) -/// `p0` is a creature DISPLAY id — the `UNIT_FIELD_MOUNTDISPLAYID` value an [`A_MOUNTED`] aura projects. -/// The importer resolves a mount spell's creature template to its display once, at import, and freezes -/// the result here; the runtime only reads it. -pub(crate) const P_DISPLAY_ID: u8 = 14; -pub(crate) const P_SPELL_ID: u8 = 15; // p0 is a game_spell id (A_SELF_RESURRECT: the Self-Resurrection Option) -pub(crate) const P_FLAT_MANA: u8 = 16; // p0 is a flat mana amount (E_SELF_RESURRECT with negative base_points) -pub(crate) const P_RAW: u8 = 255; // scripted / unresolved - -// --- TargetKind: who the effect resolves onto --- -pub(crate) const T_SELF: u8 = 0; -pub(crate) const T_TARGET_ENEMY: u8 = 1; -pub(crate) const T_TARGET_ALLY: u8 = 2; -pub(crate) const T_TARGET_ANY: u8 = 3; -pub(crate) const T_AREA_ENEMY: u8 = 4; -pub(crate) const T_AREA_ALLY: u8 = 5; -pub(crate) const T_CHAIN_ENEMY: u8 = 6; -pub(crate) const T_SCRIPTED: u8 = 7; - -// --- combat fields (the value of `p0` for an A_MOD_COMBAT aura; the combat module reads these) --- -pub(crate) const COMBAT_ATTACK_POWER: u8 = 0; -pub(crate) const COMBAT_CRIT: u8 = 1; -pub(crate) const COMBAT_HIT: u8 = 2; -pub(crate) const COMBAT_DMG_DONE: u8 = 3; -// (field 4 RETIRED — melee haste was COMBAT_HASTE, now A_MOD_SPEED(SPEED_SWING). The importer never -// emitted COMBAT_HASTE — all haste/attack-speed auras route to A_MOD_SPEED — so swing speed has ONE -// representation. 4 stays a reserved hole to keep COMBAT_SPELL_POWER's value stable.) -/// Spell power / healing power — the caster-side damage/heal scaling field. An `A_MOD_COMBAT` aura with -/// `p0 == COMBAT_SPELL_POWER` is a flat spell-power buff (e.g. a "Spell Power" trinket/food), summed -/// alongside the INT-derived bonus by `spell_power` and folded into E_DAMAGE / E_HEAL magnitudes. Reuses -/// the existing A_MOD_COMBAT aura plumbing (`combat_field_bonus`) — NO new aura kind, NO schema change. -pub(crate) const COMBAT_SPELL_POWER: u8 = 5; -// Defender/utility combat fields (talent folds): an A_MOD_COMBAT aura with one of these p0s -// adds to the matching attack-table band / skill / threat, summed by `combat_field_bonus`. They enable -// passive talents (Deflection→parry, Shield Spec→block, Anticipation→defense, Defiance→threat) and any -// future +avoidance buff. Defender-side (read on the TARGET) except THREAT (read on the threat SOURCE). -pub(crate) const COMBAT_PARRY: u8 = 6; // + parry-chance basis points (defender) -pub(crate) const COMBAT_DODGE: u8 = 7; // + dodge-chance basis points (defender) -pub(crate) const COMBAT_BLOCK: u8 = 8; // + block-chance basis points (shielded defender) -pub(crate) const COMBAT_DEFENSE: u8 = 9; // + defense SKILL points (raises avoidance vs the attacker) -pub(crate) const COMBAT_THREAT: u8 = 10; // + threat generated, signed PERCENT (threat source) - -// --- speed kinds (the value of `p0` for an A_MOD_SPEED aura; signed PERCENT amount: + faster, − slower). -// Mirrors the importer's `resolve_aura_params` mapping (ModMeleeHaste→SWING, ModDecreaseSpeed→MOVE, …). -// SWING folds into `combat::effective_swing_time`; MOVE folds into `combat::effective_move_speed` (the -// creature movement passes). CAST/MOUNTED are schema-ready, not yet read (a cast-time / mount-speed fold -// is a follow-up). A signed sum lets a haste + a slow net out. --- -pub(crate) const SPEED_MOVE: u8 = 0; -pub(crate) const SPEED_SWING: u8 = 1; -pub(crate) const SPEED_CAST: u8 = 2; -pub(crate) const SPEED_MOUNTED: u8 = 3; - -// --- STANCES / FORMS (the value WorldEntity.stance holds + the value `p0` carries on an E_SET_STANCE -// effect; 0-based, our OWN small taxonomy — THE DEFINITION SITE of the stance-id convention). 0 = Battle -// (the login/default stance — every non-stance class also sits at 0, so they're "in Battle" inertly but -// never gate on it). widened the space past the Warrior trio to the Druid COMBAT forms -// (Bear/Cat/Dire Bear — the shapeshift switches the importer name-rescues to E_SET_STANCE); the druid -// non-combat forms (Aquatic/Travel/Moonkin/Tree) remain out of scope, their markers stay inert A_FLAGs. -// The ONE convention, end to end: -// * `WorldEntity.stance` and E_SET_STANCE `p0` carry the 0-based id below; -// * the `game_spell.stances` usability mask carries bit `1 << stance` per allowed stance (a u8, ids -// 0..7 — 6 assigned, 2 spare), translated by the importer from the vanilla form-bit mask -// (`1 << (formId-1)`) via its `form_to_stance` (LOCKSTEP with this table — vanilla ShapeshiftForm -// ids: Battle=17 / Defensive=18 / Berserker=19 / Bear=5 / Cat=1 / DireBear=8); -// * the client UNIT_FIELD_BYTES_1[2] form byte is derived back by `client_form_for_stance` (math.rs). -// `stance_allows` (the cast gate) and the combat folds all key on this one 0-based id. --- -pub(crate) const STANCE_BATTLE: u8 = 0; -pub(crate) const STANCE_DEFENSIVE: u8 = 1; -pub(crate) const STANCE_BERSERKER: u8 = 2; -pub(crate) const STANCE_BEAR: u8 = 3; // Druid Bear Form (vanilla form 5) — the tank form -pub(crate) const STANCE_CAT: u8 = 4; // Druid Cat Form (vanilla form 1) -pub(crate) const STANCE_DIRE_BEAR: u8 = 5; // Druid Dire Bear Form (vanilla form 8) - -// --- Defensive Stance combat magnitudes (Option B — FIELD-keyed folds, NOT auras, so a stance switch -// clears them for free). Tuning lives in ONE place. Signed PERCENTs folded by the combat chokepoints: -// DR is added to `damage_taken_bonus` (read on the DEFENDER, both melee+spell paths); the dmg-done penalty -// is folded at the two OUTGOING-damage sites (read on the ATTACKER); the threat bonus rides `add_threat` -// (read on the threat SOURCE). All three are pure functions of `WorldEntity.stance`, so the moment -// E_SET_STANCE rewrites the field the old stance's contribution vanishes — no aura row to create/refresh/ -// delete. Vanilla Defensive Stance: −10% damage taken, −10% damage dealt, +threat. --- -pub(crate) const STANCE_DEFENSIVE_DR_PCT: i32 = -10; // incoming-damage percent while in Defensive (−10% taken) -pub(crate) const STANCE_DEFENSIVE_DMG_DONE_PCT: i32 = -10; // outgoing-damage percent while in Defensive (−10% dealt) -pub(crate) const STANCE_DEFENSIVE_THREAT_PCT: i32 = 30; // threat percent bonus while in Defensive (+30% threat generated) - -// --- projectile travel time --- -/// Generic projectile missile speed (yards/sec) for a ranged `E_DAMAGE` bolt (Shadow Bolt, Frostbolt, …). -/// Vanilla assigns each spell its own `Spell.dbc` `Speed` value; the importer does not currently surface -/// that column (see `importer/src/spell.rs`'s documented schema-bug remap, which repurposes the same wire -/// slot for `RangeIndex`), so this is one documented flat approximation shared by every ranged bolt rather -/// than a per-spell DBC value (Shadow Bolt ≈ 21 yd/s). -/// Melee/self/instant effects never read this (only a ranged `E_DAMAGE` effect does — see -/// `projectile_travel_ms`), so every non-projectile cast is unaffected. +pub(crate) const STANCE_DEFENSIVE_DR_PCT: i32 = -10; +pub(crate) const STANCE_DEFENSIVE_DMG_DONE_PCT: i32 = -10; +pub(crate) const STANCE_DEFENSIVE_THREAT_PCT: i32 = 30; pub(crate) const PROJECTILE_SPEED_YPS: f32 = 21.0; - -// --- spell-power scaling tuning (documented + unit-tested; chosen baseline-safe) --- -/// INT below this floor contributes ZERO spell power. Set to the L2 starter's intellect (~20) so the -/// existing low-INT player/creatures (no buff, no spell-power gear) derive 0 INT spell power and every -/// seed spell's magnitude stays byte-identical to today. Vanilla INT does not grant spell power at all; -/// this above-baseline ramp is our slice's modest stand-in (it only adds when INT clearly exceeds the -/// starter floor — e.g. a high-level caster), so the gating is EXPLICIT: scaling appears only with a real -/// spell-power source (a high INT OR a `COMBAT_SPELL_POWER` aura). pub(crate) const INT_SPELL_POWER_BASE: i32 = 20; -/// How many points of effective INT *above the floor* yield 1 point of spell power. A large divisor keeps -/// the INT ramp modest (a L60-ish caster at ~150 INT → (150-20)/5 = 26 spell power). pub(crate) const INT_PER_SPELL_POWER: i32 = 5; -/// Per-effect spell-power coefficient, in PERCENT (100 = 1.0 = full spell power added once). The slice -/// uses a flat 1.0 for the wired instant direct effects (E_DAMAGE / E_HEAL) — a modest, documented choice -/// (the vanilla cast-time/3.5 coefficient is a later refinement). At 0 spell power this adds nothing, so -/// the baseline magnitude is unchanged. pub(crate) const SPELL_POWER_COEFF_PCT: i32 = 100; - -// --- base attributes (the value of `p0` for an A_MOD_STAT aura; matches the UNIT_FIELD_STAT order) --- -pub(crate) const STAT_STR: u8 = 0; -pub(crate) const STAT_AGI: u8 = 1; -pub(crate) const STAT_STA: u8 = 2; -pub(crate) const STAT_INT: u8 = 3; -pub(crate) const STAT_SPI: u8 = 4; -pub(crate) const STAT_ALL: u8 = 0xFF; // an A_MOD_STAT effect that buffs every attribute (e.g. Mark of the Wild) - -// --- resistance schools (the value of `p0` for an A_MOD_RESISTANCE aura; a bitmask, armor = bit 0) --- -pub(crate) const RESIST_ARMOR: u8 = 0x01; // physical armor (bit 0 of the school mask) - -#[allow(dead_code)] -pub(crate) const ALL_INSTANT_KINDS: &[u8] = &[ - E_DAMAGE, - E_HEAL, - E_ENERGIZE, - E_DISPEL, - E_TRIGGER, - E_TAUNT, - E_CREATE_ITEM, - E_WEAPON_STRIKE, - E_CHARGE, - E_CONVERT_RESOURCE, - E_JUDGEMENT, - E_ADD_COMBO, - E_FINISHER_DAMAGE, - E_RESURRECT, - E_SCRIPTED, - E_PICKPOCKET, - E_INTERRUPT, - E_REDUCE_THREAT, - E_NEXT_SWING, - E_SET_STANCE, - E_SUMMON_PET, - E_SUMMON_HOSTILE, - E_HEAL_MAX_HEALTH, - E_ENCHANT_ITEM, - E_DISENCHANT, - E_POWER_BURN, - E_BLINK, - E_PERSISTENT_AREA, - E_FISH, - E_OPEN_LOCK, - E_RECALL_HOME, - E_DUEL, - E_TAME_CREATURE, - E_FEED_PET, - E_DISMOUNT, - E_DISTRACT, - E_SELF_RESURRECT, -]; - -/// Canonical, ordered list of every AURA (`A_*`) kind — same rationale and same fix as -/// [`ALL_INSTANT_KINDS`] above (that older hand-copied list had drifted too, missing -/// A_SPELLMOD_FLAT/A_SPELLMOD_PCT). Same `#[allow(dead_code)]` rationale as `ALL_INSTANT_KINDS` above. -#[allow(dead_code)] -pub(crate) const ALL_AURA_KINDS: &[u8] = &[ - A_PERIODIC_DAMAGE, - A_PERIODIC_HEAL, - A_PERIODIC_ENERGIZE, - A_PERIODIC_TRIGGER, - A_MOD_STAT, - A_MOD_RESISTANCE, - A_ABSORB, - A_MOD_COMBAT, - A_MOD_SPEED, - A_MOD_HEALTH_POWER, - A_MOD_DAMAGE_TAKEN, - A_SEAL, - A_STEALTH, - A_COMBAT_HEALTH_REGEN_PCT, - A_MOD_STAT_PCT, - A_PROC_TRIGGER, - A_PROC_DAMAGE, - A_SPELLMOD_FLAT, - A_SPELLMOD_PCT, - A_DISARM, - A_RETALIATE, - A_CONTROL, - A_IMMUNITY, - A_MOD_DETECT_RANGE, - A_MOUNTED, - A_SELF_RESURRECT, - A_FLAG, -]; - -// The rest of the taxonomy vocabulary (param tags, target kinds, combat fields, speed kinds, stances, -// stats) is not KIND-shaped, already has its own hand-picked (non-"exhaustive") pin in tests.rs -// (`param_tag_wire_values_exhaustive`, `mechanic_wire_values`, `kind_wire_values`, …), and has never -// drifted the way the two lists above did — so it does not need a canonical, loop-tested slice of its -// own. A subset of it has no OTHER production call site yet though (only a test-only reference, same as -// every `E_*`/`A_*` kind before `ALL_INSTANT_KINDS`/`ALL_AURA_KINDS` existed), so it still needs SOME -// aggregate to keep `dead_code` from tripping on the non-test `lib` build. It holds just the residue, -// down from `_TAXONOMY`'s ~100 entries to these 20. -#[allow(dead_code)] -const _RESERVED_NON_KIND_TAXONOMY: &[u8] = &[ - P_NONE, - P_STAT_ID, - P_SCHOOL_MASK, - P_POWER_TYPE, - P_COMBAT_FIELD, - P_SPEED_KIND, - P_FLAG, - P_ITEM_ENTRY, - P_ENTRY, - P_ENCHANT_ID, - P_SPELLMOD_OP, - P_DISPLAY_ID, - P_SPELL_ID, - P_FLAT_MANA, - P_RAW, - T_SCRIPTED, - SPEED_CAST, - SPEED_MOUNTED, - STANCE_BATTLE, - STANCE_BERSERKER, -]; - -// (No `_MECHANICS` dead-code scaffold needed: all four mechanics are wired into real gates — M_STUN and -// M_ROOT via their single-mechanic checkers `is_stunned`/`is_rooted`, and M_POLY/M_FEAR via the composite -// the stun/polymorph and fear predicates that the action/movement Gates -// (`is_action_blocked`, `is_movement_blocked`, `is_self_movement_suppressed`) then compose.) diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 11d5789c6..3323576c4 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -439,186 +439,12 @@ fn absorb_draw_soaks_then_overflows() { assert_eq!(spill2, 0); } -/// Pin the kind wire values: the seed/import data, the dispatch, and the SDK binding all key off -/// these — a drift would silently re-route an effect. KIND_AURA_BIT is the instant-vs-aura split. -#[test] -fn kind_wire_values() { - assert_eq!(KIND_AURA_BIT, 0x80); - assert_eq!(E_DAMAGE, 0x01); - assert_eq!(E_HEAL, 0x02); - assert_eq!(E_DISPEL, 0x04); - assert_eq!(A_PERIODIC_DAMAGE, 0x90); - assert_eq!(A_PERIODIC_HEAL, 0x91); - assert_eq!(A_ABSORB, 0xA2); - assert_eq!(A_MOD_COMBAT, 0xA3); - assert_eq!(A_FLAG, 0xBE); - // every aura kind has the high bit; every instant kind does not. - for k in [ - E_DAMAGE, E_HEAL, E_ENERGIZE, E_DISPEL, E_TRIGGER, E_SCRIPTED, - ] { - assert_eq!( - k & KIND_AURA_BIT, - 0, - "instant kind 0x{k:02x} must not set the aura bit" - ); - } - for k in [ - A_PERIODIC_DAMAGE, - A_PERIODIC_HEAL, - A_MOD_COMBAT, - A_FLAG, - A_CONTROL, - ] { - assert_ne!( - k & KIND_AURA_BIT, - 0, - "aura kind 0x{k:02x} must set the aura bit" - ); - } -} - -/// Pin EVERY instant `E_*` kind wire value (the deduplicated effect taxonomy the importer maps mangos -/// `Effect` ids onto): a drift here would silently re-route the importer's dispatch and desync it from -/// the seed data / the SDK binding. Loops [`ALL_INSTANT_KINDS`] — taxonomy.rs's single canonical list — -/// rather than a second hand-copied array, so a kind is covered here purely by being IN that slice -/// (exhaustive by construction: the canonical list found this test's own hand-copied predecessor missing -/// E_BLINK/E_PERSISTENT_AREA/E_FISH/E_OPEN_LOCK, and a kind left off `ALL_INSTANT_KINDS` itself now -/// fails CI's `-D warnings` clippy gate via `dead_code` instead of silently passing every test). Also -/// doubles as the passive-filter regression pin: `passive_applies_effect_kind` is exactly `kind & -/// KIND_AURA_BIT != 0`, so asserting every instant kind clears the bit is asserting every instant kind -/// is rejected by the passive-apply path (the old, separately hand-copied -/// `apply_spell_auras_rejects_instant_effects` test — now redundant with this loop and retired). #[test] -fn instant_kind_wire_values_exhaustive() { - assert_eq!( - ALL_INSTANT_KINDS.len(), - 37, - "an instant kind was added to (or removed from) taxonomy.rs without a matching, deliberate \ - change to ALL_INSTANT_KINDS — bump this count only when the taxonomy really changed" - ); - // The importer writes this value; the two lists move in lockstep. - assert_eq!(E_DISTRACT, 0x25); - for (i, k) in ALL_INSTANT_KINDS.iter().enumerate() { - assert_eq!( - k & KIND_AURA_BIT, - 0, - "instant kind 0x{k:02x} must not set the aura bit" - ); - assert!( - !passive_applies_effect_kind(*k), - "instant kind 0x{k:02x} must not reach the passive-apply path (Consecration's \ - E_PERSISTENT_AREA-only shape must never mint a spurious login/world-change buff)" - ); - for other in &ALL_INSTANT_KINDS[i + 1..] { - assert_ne!( - k, other, - "instant kinds must be distinct: 0x{k:02x} vs 0x{other:02x}" - ); - } - } -} - -/// Pin EVERY `A_*` aura kind wire value: same rationale, same [`ALL_AURA_KINDS`]-loop construction, and -/// same-filter double duty as [`instant_kind_wire_values_exhaustive`] above (that test's old sibling -/// hand-copied list was missing A_SPELLMOD_FLAT/A_SPELLMOD_PCT). This is the control: a genuinely -/// passive talent/racial (an `A_*` aura-kind effect) must keep applying through `apply_spell_auras` — an -/// over-eager filter that rejected real auras too would silently disable every passive talent in the -/// game, worse than the cosmetic bug the passive filter fixes (the old, separately hand-copied -/// `apply_spell_auras_still_applies_aura_effects` test — now redundant with this loop and retired). -#[test] -fn aura_kind_wire_values_exhaustive() { - assert_eq!( - ALL_AURA_KINDS.len(), - 27, - "an aura kind was added to (or removed from) taxonomy.rs without a matching, deliberate \ - change to ALL_AURA_KINDS — bump this count only when the taxonomy really changed" - ); - for (i, k) in ALL_AURA_KINDS.iter().enumerate() { - assert_ne!( - k & KIND_AURA_BIT, - 0, - "aura kind 0x{k:02x} must set the aura bit" - ); - assert!( - passive_applies_effect_kind(*k), - "aura kind 0x{k:02x} must still reach the passive-apply path" - ); - for other in &ALL_AURA_KINDS[i + 1..] { - assert_ne!( - k, other, - "aura kinds must be distinct: 0x{k:02x} vs 0x{other:02x}" - ); - } - } -} - -/// Pin EVERY `P_*` param-tag value (`eff_p0_kind` — what `p0` MEANS on a given effect/aura row): the -/// importer stamps these BY NAME and the readers (`resistance_bonus`, `is_immune_to_mechanic`, …) key off -/// them, so a drift here would silently reinterpret a frozen `p0`. All 17 are distinct. -#[test] -fn param_tag_wire_values_exhaustive() { - assert_eq!(P_NONE, 0); - assert_eq!(P_STAT_ID, 1); - assert_eq!(P_SCHOOL_MASK, 2); - assert_eq!(P_MECHANIC, 3); - assert_eq!(P_POWER_TYPE, 4); - assert_eq!(P_COMBAT_FIELD, 5); - assert_eq!(P_SPEED_KIND, 6); - assert_eq!(P_FLAG, 7); - assert_eq!(P_ITEM_ENTRY, 8); - assert_eq!(P_ENTRY, 9); - assert_eq!(P_ENCHANT_ID, 10); - assert_eq!(P_PCT_MAX_POWER, 12); - assert_eq!(P_GAMEOBJECT_ENTRY, 13); - assert_eq!(P_DISPLAY_ID, 14); - assert_eq!(P_SPELL_ID, 15); - assert_eq!(P_FLAT_MANA, 16); - assert_eq!(P_RAW, 255); - let all = [ - P_NONE, - P_STAT_ID, - P_SCHOOL_MASK, - P_MECHANIC, - P_POWER_TYPE, - P_COMBAT_FIELD, - P_SPEED_KIND, - P_FLAG, - P_ITEM_ENTRY, - P_ENTRY, - P_ENCHANT_ID, - P_PCT_MAX_POWER, - P_GAMEOBJECT_ENTRY, - P_DISPLAY_ID, - P_SPELL_ID, - P_FLAT_MANA, - P_RAW, - ]; - for (i, a) in all.iter().enumerate() { - for b in &all[i + 1..] { - assert_ne!(a, b, "param tags must be distinct: {a} vs {b}"); - } - } -} - -/// Pin the CC mechanic wire values: the seed (A_CONTROL eff_p0) and the gates key off these, so a -/// drift would silently un-gate a stun/root. They must be DISTINCT and all NON-ZERO (0 is the -/// reserved no-mechanic sentinel — a frozen A_CONTROL aura with eff_p0 == 0 matches no gate). -#[test] -fn mechanic_wire_values() { - assert_eq!(M_STUN, 1); - assert_eq!(M_ROOT, 2); - assert_eq!(M_FEAR, 3); - assert_eq!(M_POLY, 4); - // All distinct + non-zero (0 stays the inert "no mechanic" value). - let ms = [M_STUN, M_ROOT, M_FEAR, M_POLY]; - for (i, a) in ms.iter().enumerate() { - assert_ne!(*a, 0, "mechanic must be non-zero (0 = inert sentinel)"); - for b in &ms[i + 1..] { - assert_ne!(a, b, "mechanics must be distinct"); - } +fn passive_application_uses_only_aura_effects() { + use lyracore_shared::spell::taxonomy::EffectKind; + for kind in EffectKind::ALL { + assert_eq!(passive_applies_effect_kind(*kind as u8), kind.is_aura()); } - // A_CONTROL is an aura kind (the gates read it off a frozen game_aura row). - assert_eq!(A_CONTROL & KIND_AURA_BIT, KIND_AURA_BIT); } /// `rederive_pool` re-applies a stat-bonus delta through the SAME login curve. The two invariants diff --git a/module/src/talent.rs b/module/src/talent.rs index 06abe2e9a..177d52e00 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -135,15 +135,10 @@ crate::character_owned!(restamp, fn sweep_restamp_game_character_talent(ctx, cha // Pure rules (unit-tested) [server] // =========================================================================================== -/// The character level at which the FIRST talent point is granted (vanilla: level 10). -pub const TALENT_START_LEVEL: u32 = 10; - /// Talent points AVAILABLE to spend: `(level - 9)` earned (1/level from L10, 51 at L60) minus the points /// already `spent`, saturating at 0. Pure — unit-tested. pub(crate) fn talent_points_available(level: u32, spent: u32) -> u32 { - level - .saturating_sub(TALENT_START_LEVEL - 1) - .saturating_sub(spent) + lyracore_shared::talent::points_available(level, spent) } /// Validate a learn of a talent: returns the NEXT rank on success, else a distinguished error. Gates, in @@ -527,19 +522,7 @@ pub(crate) fn talent_rank_spell(t: &Talent, rank: u8) -> u32 { /// the imported per-rank spells. A 0 higher-rank field (demo tree, or a single-rank imported talent) falls /// back to `r1`. Rank 0 → 0 (unlearned). fn pick_rank_spell(rank: u8, r1: u32, r2: u32, r3: u32, r4: u32, r5: u32) -> u32 { - let s = match rank { - 0 => return 0, - 1 => r1, - 2 => r2, - 3 => r3, - 4 => r4, - _ => r5, - }; - if s != 0 { - s - } else { - r1 - } + lyracore_shared::talent::rank_spell(rank, [r1, r2, r3, r4, r5]) } /// Teach `talent`'s `rank`-spell into `guid`'s spellbook AND apply its effects — the shared body of diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 932552aa0..bcdf8d2be 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -90,16 +90,6 @@ pub(crate) enum BuyGrant { Spell(u32), } -/// Whether a profession offering at `cap` is ALREADY covered by the character's stored `max_rank` for -/// that skill line (its rank/cap scaling) — the pure comparison the buy path's -/// already-known gate keys on for a profession offering (a class-spell offering uses `knows_spell` -/// instead). Extracted from `apply_trainer_buy` (pure code-motion). `None` (no `game_player_skill` row -/// at all — never learned) is never capped. `stored_max_rank >= cap` is the boundary: exactly meeting -/// the cap is already-known (rejected); one below it is not (the buy proceeds and lifts the ceiling). -pub(crate) fn profession_already_capped(stored_max_rank: Option, cap: u32) -> bool { - stored_max_rank.is_some_and(|rank| rank as u32 >= cap) -} - /// Whether a rank purchase's `game_spell_chain` prerequisite is satisfied (reduced scope): /// `prev_spell == 0` — the family's first rank, or a spell with no chain concept at all — has no /// prerequisite, so it is ALWAYS allowed regardless of `knows_prev`. Otherwise the caster must already @@ -119,11 +109,11 @@ pub(crate) fn trainer_buy_check( money: u32, cost: u32, ) -> Result<(), TrainerRefusal> { - if known { - return Err(TrainerRefusal::AlreadyKnown); - } - if level < required_level { - return Err(TrainerRefusal::LevelTooLow); + use lyracore_shared::trainer::{offering_state, OfferingState}; + match offering_state(known, level, required_level) { + OfferingState::Known => return Err(TrainerRefusal::AlreadyKnown), + OfferingState::LevelTooLow => return Err(TrainerRefusal::LevelTooLow), + OfferingState::Available => {} } if money < cost { return Err(TrainerRefusal::NotEnoughMoney); @@ -132,24 +122,21 @@ pub(crate) fn trainer_buy_check( } fn learn_target_trigger(effect: &crate::SpellEffect) -> Option { - (effect.trigger_spell != 0 - && effect.kind != crate::spell::A_PERIODIC_TRIGGER - && effect.kind != crate::spell::A_FLAG - && effect.kind != crate::spell::A_PROC_TRIGGER - && effect.kind != crate::spell::A_PROC_DAMAGE - && effect.kind != crate::spell::E_TRIGGER) - .then_some(effect.trigger_spell) + lyracore_shared::trainer::learn_target_trigger(effect.kind, effect.trigger_spell) } fn learn_target_from_effects(spell_id: u32, effects: &[crate::SpellEffect]) -> u32 { - effects - .iter() - .filter_map(|effect| { - learn_target_trigger(effect).map(|target| (effect.effect_index, effect.id, target)) - }) - .min_by_key(|(effect_index, id, _)| (*effect_index, *id)) - .map(|(_, _, target)| target) - .unwrap_or(spell_id) + lyracore_shared::trainer::resolve_learn_target( + spell_id, + effects + .iter() + .map(|effect| lyracore_shared::trainer::LearnEffect { + index: effect.effect_index, + id: effect.id, + kind: effect.kind, + trigger_spell: effect.trigger_spell, + }), + ) } pub(crate) fn resolve_learn_target(ctx: &ReducerContext, spell_id: u32) -> u32 { @@ -500,33 +487,10 @@ pub(crate) fn apply_trainer_buy( ) })?; - // PROFESSION-LEARN BRANCH: a flagged offering teaches a SKILL, not a spell — - // it never resolves a wrapper/rank, never casts, never touches `game_player_spell`. `known` mirrors - // `learn_profession`'s presence check (a `game_player_skill` row for that line → a re-buy is the - // idempotent already-known no-op), and on Ok it grants the skill at 1/75. The `learn_skill_line == 0` - // arm below is the EXISTING spell path verbatim (byte-identical → no class-spell regression). - // - // WEAPON-MASTER FORK: the SAME `learn_skill_line` column shape also carries weapon - // proficiency offerings (Daggers, Polearm, …) — no new row kind. `is_combat_skill_line` tells the two - // apart and the cap/known computation diverges for each: - // - PROFESSION cap is the offering's STATIC tier column (`learn_skill_cap`: Apprentice 75, Journeyman - // 150, …), author-set and independent of level; "known" is a CAP comparison (`profession_already_capped`) - // so a higher-tier re-buy still lifts the ceiling. - // - WEAPON cap is LEVEL-DERIVED (`skill::skill_cap_for_level(caster.level)`), exactly like every other - // combat line (`raise_combat_caps`) — the offering's `learn_skill_cap` column is ignored (seeded 0/don't- - // care) for a weapon row, since a static column can never track a climbing level and a stored 0 would - // be the struct comment's "1/0 stuck-row hazard" (a cap that can never be climbed) if read literally. - // "known" is mere ROW PRESENCE (any `game_player_skill` row for that line at all): weapon proficiency - // has no tiers to re-buy, so a class-seeded line (already present via `ensure_player_skills`) is fully - // known and refuses, while a lacked line proceeds — matching vanilla's "already knows Swords" refusal. - // - // RIDING FORK: the same column carries a riding offering (`learn_skill_line` = 762). It takes the cap - // from the offering's static tier like a profession, but grants through `learn_riding` — riding is - // trained WHOLE (current == max == the tier) because the mount cast gate reads the CURRENT rank. let profession_line = offered.learn_skill_line; let is_weapon_learn = profession_line != 0 && crate::skill::is_combat_skill_line(profession_line); - let (grant, known): (BuyGrant, bool) = if crate::skill::is_riding_skill_line(profession_line) { + let grant = if crate::skill::is_riding_skill_line(profession_line) { if ctx .db .game_creature_template() @@ -539,56 +503,34 @@ pub(crate) fn apply_trainer_buy( "that trainer does not teach riding", )); } - // "Known" is the same TIER comparison a profession uses: Apprentice-75 has not met Journeyman-150, - // so the second tier is buyable exactly once and a re-buy of the tier you hold is refused. - let stored_max_rank = ctx - .db - .game_player_skill() - .by_character() - .filter(&caster_guid) - .find(|s| s.skill_line == profession_line) - .map(|r| r.max_rank); - ( - BuyGrant::Riding(offered.learn_skill_cap), - profession_already_capped(stored_max_rank, offered.learn_skill_cap), - ) + BuyGrant::Riding(offered.learn_skill_cap) } else if is_weapon_learn { - let cap = crate::skill::skill_cap_for_level(caster.level); - let present = ctx - .db - .game_player_skill() - .by_character() - .filter(&caster_guid) - .any(|s| s.skill_line == profession_line); - (BuyGrant::Profession(profession_line, cap), present) - } else if profession_line != 0 { - // Already-known keys on the CAP, not mere row presence (rank/cap scaling): "known" iff a row exists - // whose `max_rank` already meets-or-exceeds THIS offering's cap. So an Apprentice re-buy (cap 75 vs - // stored 75) is rejected for the default cap, but buying Journeyman (cap 150) while at - // apprentice-75 PASSES the gate and lifts the ceiling, and a 2nd Journeyman buy (150 >= 150) is - // then rejected. - let stored_max_rank = ctx - .db - .game_player_skill() - .by_character() - .filter(&caster_guid) - .find(|s| s.skill_line == profession_line) - .map(|r| r.max_rank); - ( - BuyGrant::Profession(profession_line, offered.learn_skill_cap), - profession_already_capped(stored_max_rank, offered.learn_skill_cap), + BuyGrant::Profession( + profession_line, + crate::skill::skill_cap_for_level(caster.level), ) + } else if profession_line != 0 { + BuyGrant::Profession(profession_line, offered.learn_skill_cap) } else { - // The castable RANK behind the offering — extracted to `resolve_learn_target` (see its doc) so - // a Package's trainer-kit pass resolves wrappers IDENTICALLY to a real buy. - // The already-known gate keys on the RANK so a re-buy of a known rank is rejected (not silently - // re-charged). - let to_learn = resolve_learn_target(ctx, spell_id); - ( - BuyGrant::Spell(to_learn), - crate::spell::knows_spell(ctx, caster_guid, to_learn), - ) + BuyGrant::Spell(resolve_learn_target(ctx, spell_id)) + }; + let stored_cap = ctx + .db + .game_player_skill() + .by_character() + .filter(&caster_guid) + .find(|skill| skill.skill_line == profession_line) + .map(|skill| u32::from(skill.max_rank)); + let knows_spell = match grant { + BuyGrant::Spell(to_learn) => crate::spell::knows_spell(ctx, caster_guid, to_learn), + _ => false, }; + let known = lyracore_shared::trainer::offering_known( + profession_line, + offered.learn_skill_cap, + stored_cap, + knows_spell, + ); if !known { if let BuyGrant::Spell(to_learn) = grant { @@ -706,23 +648,23 @@ mod tests { #[test] fn profession_already_capped_compares_stored_max_rank_against_the_offering_cap() { assert!( - !profession_already_capped(None, 75), + !lyracore_shared::trainer::offering_known(185, 75, None, false), "never learned -> not capped" ); assert!( - profession_already_capped(Some(75), 75), + lyracore_shared::trainer::offering_known(185, 75, Some(75), false), "Apprentice re-buy at the same cap is rejected" ); assert!( - !profession_already_capped(Some(75), 150), + !lyracore_shared::trainer::offering_known(185, 150, Some(75), false), "Apprentice-75 has NOT met the Journeyman-150 cap" ); assert!( - profession_already_capped(Some(150), 150), + lyracore_shared::trainer::offering_known(185, 150, Some(150), false), "a 2nd Journeyman buy (150 >= 150) is rejected" ); assert!( - !profession_already_capped(Some(74), 75), + !lyracore_shared::trainer::offering_known(185, 75, Some(74), false), "one rank below the cap is not yet capped" ); } @@ -750,19 +692,19 @@ mod tests { #[test] fn a_riding_tier_is_buyable_once_and_the_next_tier_still_sells() { assert!( - !profession_already_capped(None, 75), + !lyracore_shared::trainer::offering_known(762, 75, None, false), "an untrained rider can buy Apprentice" ); assert!( - profession_already_capped(Some(75), 75), + lyracore_shared::trainer::offering_known(762, 75, Some(75), false), "re-buying Apprentice is refused" ); assert!( - !profession_already_capped(Some(75), 150), + !lyracore_shared::trainer::offering_known(762, 150, Some(75), false), "Apprentice-75 has not met the Journeyman-150 tier" ); assert!( - profession_already_capped(Some(150), 150), + lyracore_shared::trainer::offering_known(762, 150, Some(150), false), "re-buying Journeyman is refused" ); } diff --git a/module/tests/distract.rs b/module/tests/distract.rs index 699ccb805..529e3198c 100644 --- a/module/tests/distract.rs +++ b/module/tests/distract.rs @@ -5,6 +5,10 @@ mod support; +use lyracore_shared::spell::taxonomy::{ + A_STEALTH, E_DISTRACT, SPELL_ATTR_STEALTH_SAFE, T_AREA_ENEMY, T_SELF, +}; + use std::collections::BTreeMap; use std::f32::consts::{FRAC_PI_4, TAU}; use std::time::Duration; @@ -19,11 +23,6 @@ const TEST_WOLF_ENTRY: u32 = 51000; const DISTRACT: u32 = 1725; const FIXTURE_STEALTH: u32 = 509_0700; -const E_DISTRACT: u32 = 0x25; -const A_STEALTH: u32 = 0xA8; -const T_SELF: u32 = 0; -const T_AREA_ENEMY: u32 = 4; -const SPELL_ATTR_STEALTH_SAFE: u32 = 0x0004; const POWER_ENERGY: u32 = 3; const MOVEMENT_RANDOM: u32 = 1; @@ -85,7 +84,15 @@ fn shard(name: &str) -> (Standalone, Line) { 0, SPELL_ATTR_STEALTH_SAFE, ); - insert_effect(&shard, DISTRACT, E_DISTRACT, 10, 1, T_AREA_ENEMY, 10.0); + insert_effect( + &shard, + DISTRACT, + u32::from(E_DISTRACT), + 10, + 1, + u32::from(T_AREA_ENEMY), + 10.0, + ); seed_hostile_factions(&shard); let player = entity(&shard, PLAYER); let line = Line { @@ -294,7 +301,15 @@ fn distract_turns_only_the_idle_creatures_near_the_point_and_keeps_the_rogue_hid 600_000, 0, ); - insert_effect(&shard, FIXTURE_STEALTH, A_STEALTH, 0, 0, T_SELF, 0.0); + insert_effect( + &shard, + FIXTURE_STEALTH, + u32::from(A_STEALTH), + 0, + 0, + u32::from(T_SELF), + 0.0, + ); shard.assert_call( "debug_force_cast_at", &[ diff --git a/module/tests/package_api/contract.rs b/module/tests/package_api/contract.rs index 7a2f45a93..1d875ed8c 100644 --- a/module/tests/package_api/contract.rs +++ b/module/tests/package_api/contract.rs @@ -1,4 +1,4 @@ -//! Package API version 3: the crate-root paths a Package may name. +//! Package API version 4: the crate-root paths a Package may name. use std::collections::BTreeSet; use std::fs; diff --git a/module/tests/package_api/fixtures/clean/src/mod.rs b/module/tests/package_api/fixtures/clean/src/mod.rs index bb243894e..baf273388 100644 --- a/module/tests/package_api/fixtures/clean/src/mod.rs +++ b/module/tests/package_api/fixtures/clean/src/mod.rs @@ -1,4 +1,4 @@ -//! A Package that names only Package API version 3. `crate::spell::pending_cast` here is inert. +//! A Package that names only Package API version 4. `crate::spell::pending_cast` here is inert. mod fixture; mod runner; diff --git a/module/tests/package_api/mod.rs b/module/tests/package_api/mod.rs index 32d49e583..6c4f0aaae 100644 --- a/module/tests/package_api/mod.rs +++ b/module/tests/package_api/mod.rs @@ -1,5 +1,5 @@ //! The Package API lint: every crate-root path an installed Package names is on Package API -//! version 3, as `docs/package-api.md` documents it. +//! version 4, as `docs/package-api.md` documents it. mod contract; mod paths; @@ -56,7 +56,7 @@ impl fmt::Display for Diagnostic { Finding::Outside { path, replacement } => { write!( f, - "Package `{package}` names `{path}` at {at}, outside Package API version 3 \ + "Package `{package}` names `{path}` at {at}, outside Package API version 4 \ (docs/package-api.md). " )?; match replacement { diff --git a/module/tests/package_api_lint.rs b/module/tests/package_api_lint.rs index 975dad973..e7da56782 100644 --- a/module/tests/package_api_lint.rs +++ b/module/tests/package_api_lint.rs @@ -1,5 +1,5 @@ //! The Package API lint, an Architecture Test: every installed Package names only Package API -//! version 3. Core CI runs it with no Package installed; the official Packages job runs it with +//! version 4. Core CI runs it with no Package installed; the official Packages job runs it with //! the collection installed. mod package_api; @@ -20,7 +20,7 @@ fn contract() -> Contract { fn the_documented_version_and_roots_match_the_lint() { let document = std::fs::read_to_string(manifest_dir().join("../docs/package-api.md")) .expect("read the Package API contract"); - assert_eq!(document.lines().next(), Some("# Package API, version 3")); + assert_eq!(document.lines().next(), Some("# Package API, version 4")); let roots = document .split_once("### Module roots") .expect("the contract documents Module roots") @@ -199,7 +199,7 @@ fn the_fixture_inventory_reports_each_finding_with_its_location() { let fixtures = manifest_dir().join("tests/package_api/fixtures"); let diagnostics = check_inventory(&contract(), &fixtures).expect_err("denied has findings"); let lines: Vec = diagnostics.iter().map(Diagnostic::to_string).collect(); - let outside = "outside Package API version 3 (docs/package-api.md)."; + let outside = "outside Package API version 4 (docs/package-api.md)."; let any_root = "Name a path under `crate::actor`, `crate::hooks`, `crate::package`, \ `crate::tables`, `crate::script_binding`, or write `// package-api: exempt \ ` on that line and raise the gap with the maintainers."; diff --git a/module/tests/queued_strike_fizzle.rs b/module/tests/queued_strike_fizzle.rs index 7d274a9a0..0bc97f433 100644 --- a/module/tests/queued_strike_fizzle.rs +++ b/module/tests/queued_strike_fizzle.rs @@ -5,6 +5,8 @@ mod support; +use lyracore_shared::spell::taxonomy::{E_NEXT_SWING, T_TARGET_ENEMY}; + use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; @@ -160,8 +162,6 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { /// `E_NEXT_SWING` effect on the target, which is the shape the importer gives Heroic Strike and /// Cleave. No global cooldown, so the staging casts below never wait on one. fn stage_on_next_swing_spell(standalone: &Standalone, spell_id: u32, name: &str, cost: u32) { - const E_NEXT_SWING: u32 = 0x13; - const T_TARGET_ENEMY: u32 = 1; const RAGE: u32 = 1; standalone.assert_sql(&format!( "INSERT INTO game_spell (spell_id, name, power_type, cost, cast_time_ms, gcd_ms, \ diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index c8ea3da8a..448b206bc 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -7,6 +7,8 @@ mod support; +use lyracore_shared::spell::taxonomy::{E_ENERGIZE, E_SCRIPTED, T_SELF, T_TARGET_ANY}; + use std::collections::BTreeMap; use lyracore_shared::constants::unit_flags; @@ -26,11 +28,6 @@ const HEAL_SCRIPT: u32 = 100_900; const GATE_SCRIPT: u32 = 100_902; const FAILING_SCRIPT: u32 = 100_903; -const E_SCRIPTED: u32 = 0x0F; -const E_ENERGIZE: u32 = 0x03; -const T_SELF: u32 = 0; -const T_TARGET_ANY: u32 = 3; - const HEAL_SCRIPT_COST: u32 = 30; const GATE_SPELL_COST: u32 = 40; @@ -54,9 +51,9 @@ fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_ca &standalone, E_HEAL_SCRIPT_SPELL, 0, - E_SCRIPTED, + u32::from(E_SCRIPTED), 0, - T_TARGET_ANY, + u32::from(T_TARGET_ANY), HEAL_SCRIPT, ); insert_script( @@ -119,9 +116,9 @@ fn a_scripted_effect_with_script_id_zero_stays_the_vanilla_no_op() { &standalone, E_NOOP_SCRIPT_ID_ZERO_SPELL, 0, - E_SCRIPTED, + u32::from(E_SCRIPTED), 0, - T_TARGET_ANY, + u32::from(T_TARGET_ANY), 0, ); @@ -171,9 +168,9 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { &standalone, E_GATE_SPELL, 0, - E_SCRIPTED, + u32::from(E_SCRIPTED), 0, - T_TARGET_ANY, + u32::from(T_TARGET_ANY), GATE_SCRIPT, ); let wolf = spawn_wolf(&standalone); @@ -261,9 +258,9 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { &standalone, E_PARTIAL_FAILURE_SPELL, 0, - E_ENERGIZE, + u32::from(E_ENERGIZE), 50, - T_SELF, + u32::from(T_SELF), 0, ); // Effect 1: a script that stages a heal and then fails — the staged heal must never commit. @@ -271,9 +268,9 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { &standalone, E_PARTIAL_FAILURE_SPELL, 1, - E_SCRIPTED, + u32::from(E_SCRIPTED), 0, - T_TARGET_ANY, + u32::from(T_TARGET_ANY), FAILING_SCRIPT, ); insert_script( diff --git a/module/tests/self_resurrection.rs b/module/tests/self_resurrection.rs index f237bc29c..7365f17e1 100644 --- a/module/tests/self_resurrection.rs +++ b/module/tests/self_resurrection.rs @@ -3,6 +3,10 @@ mod support; +use lyracore_shared::spell::taxonomy::{ + A_SELF_RESURRECT, E_SELF_RESURRECT, P_FLAT_MANA, P_SPELL_ID, T_SELF, T_TARGET_ALLY, +}; + use std::collections::BTreeMap; use lyracore_shared::constants::{player_flags, unit_flags, unit_vis_flags}; @@ -19,13 +23,6 @@ const SOULSTONE_HEALTH: u32 = 400; const SOULSTONE_MANA: u32 = 700; const RESURRECTION_SICKNESS: u32 = 15007; -const E_SELF_RESURRECT: u32 = 0x26; -const A_SELF_RESURRECT: u32 = 0xB5; -const P_SPELL_ID: u32 = 15; -const P_FLAT_MANA: u32 = 16; -const T_SELF: u32 = 0; -const T_TARGET_ALLY: u32 = 2; - const WARRIOR: u32 = 1; const ROGUE: u32 = 4; const WARLOCK: u32 = 9; @@ -178,21 +175,21 @@ fn start(name: &str) -> Standalone { insert_effect( &standalone, SOULSTONE_AURA, - A_SELF_RESURRECT, + u32::from(A_SELF_RESURRECT), 0, - T_TARGET_ALLY, + u32::from(T_TARGET_ALLY), SOULSTONE_SELF_RES as i32, - P_SPELL_ID, + u32::from(P_SPELL_ID), ); insert_spell(&standalone, SOULSTONE_SELF_RES, "Use Soulstone", 0); insert_effect( &standalone, SOULSTONE_SELF_RES, - E_SELF_RESURRECT, + u32::from(E_SELF_RESURRECT), -(SOULSTONE_HEALTH as i32), - T_SELF, + u32::from(T_SELF), SOULSTONE_MANA as i32, - P_FLAT_MANA, + u32::from(P_FLAT_MANA), ); standalone }