diff --git a/CONTEXT.md b/CONTEXT.md index 5310032b5..482142ada 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -292,6 +292,12 @@ _Avoid_: other unit, opponent The cast a fired Proc starts. It runs the cast core's effect loop and nothing else: no Gate, no cost, no cooldown, no stealth break. Its own hits fire no further Procs. _Avoid_: proc cast, internal cast, free cast +**Spell Cast Event Kind**: +The append-only code naming the wire signal in one `game_spell_cast_event` row. Zero means a row +from the old schema. New rows use 1 START, 2 GO, 3 INTERRUPT, 4 PUSHBACK, or 5 PROC_LOG. A Gateway +does not guess how to decode an unknown nonzero code. +_Avoid_: cast flags, event flags, signal flags + ### Creature AI **Creature-AI Family**: diff --git a/gateway/src/stdb/bindings/spell_cast_event_type.rs b/gateway/src/stdb/bindings/spell_cast_event_type.rs index 9ead71b6b..c36a57d69 100644 --- a/gateway/src/stdb/bindings/spell_cast_event_type.rs +++ b/gateway/src/stdb/bindings/spell_cast_event_type.rs @@ -31,6 +31,7 @@ pub struct SpellCastEvent { pub grid_x: i32, pub grid_y: i32, pub failure_reason: u8, + pub kind: u8, } impl __sdk::InModule for SpellCastEvent { @@ -65,6 +66,7 @@ pub struct SpellCastEventCols { pub grid_x: __sdk::__query_builder::Col, pub grid_y: __sdk::__query_builder::Col, pub failure_reason: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for SpellCastEvent { @@ -95,6 +97,7 @@ impl __sdk::__query_builder::HasCols for SpellCastEvent { grid_x: __sdk::__query_builder::Col::new(table_name, "grid_x"), grid_y: __sdk::__query_builder::Col::new(table_name, "grid_y"), failure_reason: __sdk::__query_builder::Col::new(table_name, "failure_reason"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), } } } diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 80bd2eb0b..bec496a85 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1227,17 +1227,56 @@ pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec ))] } -/// Spell cast visuals: the ONE body both legs run — the full cast-lock -/// contract (START/GO sequencing, interrupt teardown, pushback, proc log, damage/heal logs, -/// cooldown), pure over the row + the viewer's own guid. Every caster-private branch keys on -/// `self_guid` exactly as the per-player closure did. +#[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(); - // INTERRUPT signal (cast-interrupt-on-damage): the victim's mid-cast timed spell was cancelled. + 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 — the cast never resolved). This row carries ONLY caster/spell, - // so it must be handled before the START/GO/COOLDOWN sequence below (it has cast_time_ms 0). - if row.is_interrupted { + // 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 @@ -1269,15 +1308,9 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec 0 { + // 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), @@ -1285,7 +1318,7 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec 0 { let log = codec::build_spell_non_melee_damage_log( row.target_guid, @@ -1303,7 +1336,7 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec 0 { + 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 = @@ -1313,7 +1346,7 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec SpellCastEvent { + fn spell_cast_event(kind: u8) -> SpellCastEvent { SpellCastEvent { id: 1, caster_guid: HEROIC_STRIKE_CASTER, - spell_id, + spell_id: 133, created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - target_guid: 0, + target_guid: 11, cast_time_ms: 0, is_completion: false, damage: 0, @@ -4436,7 +4467,7 @@ mod tests { is_crit: false, resisted: 0, absorbed: 0, - is_interrupted: true, + is_interrupted: false, cooldown_ms: 0, delay_ms: 0, healed: 0, @@ -4447,10 +4478,156 @@ mod tests { instance_id: 0, grid_x: 0, grid_y: 0, + failure_reason: 0, + kind, + } + } + + /// An interruption signal row for `spell_id` cast by `HEROIC_STRIKE_CASTER`, carrying + /// `failure_reason`. Every other field stays at the module baseline's zero, which is what such a + /// row really looks like on the wire. + fn interrupted_cast(spell_id: u32, failure_reason: u8) -> SpellCastEvent { + SpellCastEvent { + spell_id, + target_guid: 0, + is_interrupted: true, failure_reason, + ..spell_cast_event(3) } } + #[test] + fn explicit_spell_cast_event_kinds_override_legacy_discriminators() { + let contradictory = |kind| SpellCastEvent { + cast_time_ms: 1_500, + is_completion: true, + damage: 23, + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + kind, + ..spell_cast_event(kind) + }; + + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &contradictory(1)).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] + )); + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &contradictory(2)).as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)), + Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_)) + ] + )); + + let interrupt = SpellCastEvent { + kind: 3, + ..spell_cast_event(3) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); + + let pushback = SpellCastEvent { + delay_ms: 500, + kind: 4, + ..spell_cast_event(4) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] + )); + + let proc_log = SpellCastEvent { + damage: 23, + kind: 5, + ..spell_cast_event(5) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) + )] + )); + } + + #[test] + fn legacy_spell_cast_event_rows_keep_the_field_based_decoder() { + let start = SpellCastEvent { + cast_time_ms: 1_500, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &start).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] + )); + + let go = spell_cast_event(0); + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &go).as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_)), + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)) + ] + )); + + let interrupt = SpellCastEvent { + is_interrupted: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); + + let pushback = SpellCastEvent { + delay_ms: 500, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] + )); + + let proc_log = SpellCastEvent { + damage: 23, + is_proc_log: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) + )] + )); + + let old_precedence = SpellCastEvent { + cast_time_ms: 1_500, + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &old_precedence).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); + } + + #[test] + fn unknown_spell_cast_event_kind_emits_no_packet() { + let row = SpellCastEvent { + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + kind: u8::MAX, + ..spell_cast_event(u8::MAX) + }; + assert!(cast_event_outbound(HEROIC_STRIKE_CASTER, &row).is_empty()); + } + /// A queued strike that could not pay its cost at the swing: the caster gets the cast-bar /// teardown and then the failed cast result naming the queued spell with the vanilla Not Enough /// Power reason (0x4D). No SMSG_SPELL_GO — the strike never fired. diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index f4d45a29a..04faaba55 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -227,6 +227,7 @@ fn cast(realm: &Realm, caster: u64, target: u64) -> SpellCastEvent { grid_x, grid_y, failure_reason: 0, + kind: 2, } } diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index 5d27cfd0a..36ad939b4 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -229,6 +229,7 @@ impl SyntheticRealm { grid_x: gx, grid_y: gy, failure_reason: 0, + kind: 2, } } diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index e97e3fcd8..451c11643 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -741,7 +741,7 @@ parity_test!(parity_game_spell_cast_event, "game_spell_cast_event", lyracore_mod id, caster_guid, spell_id, created_at, target_guid, cast_time_ms, is_completion, damage, school, is_crit, resisted, absorbed, is_interrupted, cooldown_ms, delay_ms, healed, is_proc_log, swing_hit_info, client_initiated, map_id, instance_id, grid_x, grid_y, - failure_reason, + failure_reason, kind, }); parity_test!(parity_game_spell_impact_event, "game_spell_impact_event", lyracore_module::SpellImpactEvent, bindings::spell_impact_event_type::SpellImpactEvent, { id, caster_guid, target_guid, spell_id, created_at, damage, school, is_crit, resisted, diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 34fd19ac8..2be3c0d1e 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -11,7 +11,7 @@ use spacetimedb::{reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::{ game_item_instance, game_item_template, game_spell, game_spell_cast_event, game_world_entity, - SpellCastEvent, WorldEntity, + SpellCastEvent, SpellCastEventKind, WorldEntity, }; // Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `apply_hit`, @@ -670,7 +670,12 @@ fn fire_melee_swing( ctx.db.game_spell_cast_event().insert(SpellCastEvent { is_interrupted: true, // SMSG_SPELL_FAILURE → the caster's cast bar tears down failure_reason: crate::spell::CAST_FAIL_NO_POWER, // → "Not enough rage" - ..SpellCastEvent::signal_at(ctx, attacker, queued_spell) + ..SpellCastEvent::signal_at( + ctx, + attacker, + queued_spell, + SpellCastEventKind::Interrupt, + ) }); } } @@ -741,7 +746,7 @@ fn fire_melee_swing( // The swing outcome — on a 0-damage fire the relay shapes the GO miss list from it // (yellow "Heroic Strike missed/was dodged/was parried", not a white MISS line). swing_hit_info: hit_info, - ..SpellCastEvent::signal_at(ctx, attacker, queued_spell) + ..SpellCastEvent::signal_at(ctx, attacker, queued_spell, SpellCastEventKind::Go) }); } // 114 FIX (b): the seal's holy portion — a log-only row (is_proc_log): the gateway sends ONLY @@ -753,7 +758,7 @@ fn fire_melee_swing( damage: projected_seal, school: 1, // holy (school_mask 2 → index 1, the mask→index rule in resolve_cast_at) is_proc_log: true, - ..SpellCastEvent::signal_at(ctx, attacker, seal_spell) + ..SpellCastEvent::signal_at(ctx, attacker, seal_spell, SpellCastEventKind::ProcLog) }); } @@ -986,7 +991,12 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { damage: damage.amount, is_crit: shot.is_crit, is_proc_log: true, - ..SpellCastEvent::signal_at(ctx, &attacker, shot.ranged_spell_id) + ..SpellCastEvent::signal_at( + ctx, + &attacker, + shot.ranged_spell_id, + SpellCastEventKind::ProcLog, + ) }); } if outcome.duel_completed { diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index eed992b54..c4e623811 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -430,7 +430,7 @@ fn run_spell_effects( // The cooldown starts at the GO (the spell fired). Only spells with a real cooldown send the packet. cooldown_ms: hdr.cooldown_ms, client_initiated, - ..SpellCastEvent::signal(ctx, caster_guid, hdr.spell_id) + ..SpellCastEvent::signal(ctx, caster_guid, hdr.spell_id, SpellCastEventKind::Go) }); } @@ -1338,7 +1338,7 @@ fn begin_cast_with_admission( cast_time_ms: completion_ms, // A cast-START is never a completion and carries no damage/cooldown (the GO event at resolve // does both); the baseline's is_interrupted:false/delay_ms:0 already hold. - ..SpellCastEvent::signal(ctx, caster_guid, spell_id) + ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Start) }); Ok(()) } diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 1da8c6826..05cea0d8b 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -595,7 +595,12 @@ pub(crate) fn aura_apply( ctx.db.game_spell_cast_event().insert(SpellCastEvent { // aura-slot overflow is an interrupt signal, not a pushback (delay_ms stays 0). is_interrupted: true, - ..SpellCastEvent::signal(ctx, caster_guid, hdr.spell_id) + ..SpellCastEvent::signal( + ctx, + caster_guid, + hdr.spell_id, + SpellCastEventKind::Interrupt, + ) }); return; }; diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 4e86187ad..97b50ac78 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -363,7 +363,7 @@ pub(crate) fn pushback_cast(ctx: &ReducerContext, caster_guid: u64) { // All other cols default/zero (this is neither a START, a GO, nor an interrupt). ctx.db.game_spell_cast_event().insert(SpellCastEvent { delay_ms: CAST_PUSHBACK_MS as u32, - ..SpellCastEvent::signal(ctx, caster_guid, spell_id) + ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Pushback) }); log::info!( "cast pushed back: caster {caster_guid}'s spell {spell_id} delayed {CAST_PUSHBACK_MS}ms \ @@ -395,7 +395,7 @@ pub(crate) fn interrupt_cast(ctx: &ReducerContext, caster_guid: u64) -> bool { // take the START branch; damage 0 so no damage log. All other cols default. ctx.db.game_spell_cast_event().insert(SpellCastEvent { is_interrupted: true, - ..SpellCastEvent::signal(ctx, caster_guid, spell_id) + ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Interrupt) }); } if interrupted { diff --git a/module/src/spell/proc.rs b/module/src/spell/proc.rs index 24e26afda..315b4b90d 100644 --- a/module/src/spell/proc.rs +++ b/module/src/spell/proc.rs @@ -572,7 +572,12 @@ fn fire_proc_damage(ctx: &ReducerContext, row: &Aura, counterparty_guid: u64) -> resisted: (row.amount as u32).saturating_sub(after_resist.max(0) as u32), absorbed, is_proc_log: true, - ..SpellCastEvent::signal(ctx, row.target_guid, row.spell_id) + ..SpellCastEvent::signal( + ctx, + row.target_guid, + row.spell_id, + SpellCastEventKind::ProcLog, + ) }); } true diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index c901a2641..47b3c8e21 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -54,7 +54,12 @@ pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { // is_interrupted signal → the gateway relays SMSG_SPELL_FAILURE to the caster, tearing the bar down. ctx.db.game_spell_cast_event().insert(SpellCastEvent { is_interrupted: true, - ..SpellCastEvent::signal(ctx, sched.caster_guid, sched.spell_id) + ..SpellCastEvent::signal( + ctx, + sched.caster_guid, + sched.spell_id, + SpellCastEventKind::Interrupt, + ) }); } } @@ -831,7 +836,12 @@ pub fn tick_ground_areas(ctx: &ReducerContext, _schedule: GroundAreaSchedule) { resisted: (a.amount.max(0) as u32).saturating_sub(resisted.max(0) as u32), absorbed, is_proc_log: true, - ..crate::spell::SpellCastEvent::signal(ctx, a.caster_guid, a.spell_id) + ..crate::spell::SpellCastEvent::signal( + ctx, + a.caster_guid, + a.spell_id, + SpellCastEventKind::ProcLog, + ) }); } } diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index 9fc3b4314..8b897a846 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -316,6 +316,18 @@ pub const CAST_FAIL_NONE: u8 = 0; /// ("Not enough rage"), which is what releases the client's lit on-next-swing button. pub const CAST_FAIL_NO_POWER: u8 = 1; +/// The signal carried by one [`SpellCastEvent`]. Codes are append-only because the Gateway reads +/// them from durable rows during a rolling Module and Gateway update. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub(crate) enum SpellCastEventKind { + Start = 1, + Go = 2, + Interrupt = 3, + Pushback = 4, + ProcLog = 5, +} + /// A spell cast (the visual). Separate from the aura row so a re-cast always replays the cast /// animation/SFX (`SMSG_SPELL_GO`) even when the aura only refreshes its timer. Reaped by the event GC. #[table( @@ -339,12 +351,9 @@ pub struct SpellCastEvent { // cast-bar duration; 0 on the cast-GO event (instant or completion). END-appended + defaulted. #[default(0u32)] pub cast_time_ms: u32, - // true on a TIMED-cast COMPLETION (the GO event from resolve_cast_at via fire_pending_cast); false on a - // begin-START, a genuine instant cast, a channel/creature cast. CURRENTLY UNUSED on the relay path: the - // gateway emits the SAME START(0)+GO+COOLDOWN close sequence for a completion and a genuine instant (the - // 5875 client closes its cast state on a START→GO pair, like the instant-cast + channel-tick relays). - // Kept (additive, harmless) for a future relay that wants to distinguish them. END-appended + - // #[default(false)] → auto-migrates. + // true on a timed completion or queued strike GO. The Gateway uses it to avoid a second START + // and to send the caster's successful cast result. It is payload within the GO kind, not the + // event discriminator. Kept for schema compatibility. END-appended + #[default(false)]. #[default(false)] pub is_completion: bool, // Total post-mitigation spell damage dealt by this cast (summed across E_DAMAGE/E_WEAPON_STRIKE @@ -371,8 +380,8 @@ pub struct SpellCastEvent { // primary target). Drives the damage log's `absorbed_damage` field ("(N absorbed)"). END-appended + defaulted. #[default(0u32)] pub absorbed: u32, - // True on an INTERRUPT signal row (the victim's mid-cast timed spell was cancelled by direct damage, - // or an on-next-swing strike could not pay its cost at the swing). The gateway relays + // Legacy compatibility field on an INTERRUPT row (the victim's mid-cast timed spell was cancelled + // by direct damage, or an on-next-swing strike could not pay its cost at the swing). The gateway relays // SMSG_SPELL_FAILURE{spell, result=Interrupted} to the caster, plus the failed cast result when // `failure_reason` names one. Such a row carries no cast-START/GO/COOLDOWN sequence. END-appended // + #[default(false)] → additive auto-migration (the publish-migration rule). @@ -386,13 +395,9 @@ pub struct SpellCastEvent { // cast each spell once). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub cooldown_ms: u32, - // PUSHBACK signal (work-item 039): >0 on a pushback row (direct damage slid the caster's in-progress - // TIMED cast's fire time by this many ms) — the gateway relays SMSG_SPELL_DELAYED{guid, delay_time} so - // the client's cast bar visibly shifts. This is the ONLY field set on such a row besides - // caster_guid/spell_id (cast_time_ms/is_completion stay 0/false — it is neither a START nor a GO). - // `game_spell_cast_event` is gateway-subscribed (per-player, `stdb/subscriptions.rs`), so this - // END-appended column needs the binding hand-synced (`gateway/src/stdb/bindings/spell_cast_event_type.rs`) - // — a hand-maintained binding not mirrored on a schema change breaks live row decode silently. + // PUSHBACK payload (work-item 039): the number of milliseconds added to the in-progress timed + // cast. The Gateway relays SMSG_SPELL_DELAYED{guid, delay_time}. Kind names the signal; this + // value remains zero on every other kind and preserves decoding for old rows. // END-appended + #[default(0u32)] → additive auto-migration (the publish-migration rule). #[default(0u32)] pub delay_ms: u32, @@ -402,11 +407,9 @@ pub struct SpellCastEvent { // delay_ms note above). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub healed: u32, - // PROC-LOG signal (114): true on a swing-proc damage line (Seal of Righteousness holy riding a - // landed melee swing). The gateway sends ONLY SMSG_SPELLNONMELEEDAMAGELOG — no START/GO/cooldown - // (nothing "casts"; the seal aura is already up). Distinct from an on-next-swing FIRE (Heroic - // Strike), which rides the normal is_completion=true relay (CAST_RESULT(OK)+GO alone) because the - // client holds a pending cast for it. Binding hand-synced (see the delay_ms note above). + // Legacy compatibility field on a PROC_LOG row (114), such as Seal of Righteousness damage on a + // landed melee swing. The Gateway sends only SMSG_SPELLNONMELEEDAMAGELOG. An on-next-swing fire + // uses GO with is_completion=true because the client holds a pending cast for it. // END-appended + #[default(false)] -> additive auto-migration. #[default(false)] pub is_proc_log: bool, @@ -437,14 +440,19 @@ pub struct SpellCastEvent { pub grid_x: i32, #[default(0i32)] pub grid_y: i32, - // Why this cast failed (`CAST_FAIL_*`), on an `is_interrupted` row. A deferred on-next-swing + // Why this cast failed (`CAST_FAIL_*`), on an INTERRUPT row. A deferred on-next-swing // strike that cannot pay its cost at the swing carries `CAST_FAIL_NO_POWER`, so the gateway // follows the teardown with a failed SMSG_CAST_RESULT naming the queued spell — without it the // 1.12 client keeps the ability latched as its current melee spell. `CAST_FAIL_NONE` on every - // other row keeps the plain teardown. Binding hand-synced (see the delay_ms note above). + // other row keeps the plain teardown. // END-appended + #[default(0u8)] -> additive auto-migration. #[default(0u8)] pub failure_reason: u8, + // The explicit Spell Cast Event Kind. Zero identifies a row written before this column existed, + // so the Gateway decodes that row from the legacy fields. New Module rows always use a nonzero + // append-only code. END-appended with a typed default for additive auto-migration. + #[default(0u8)] + pub kind: u8, } impl SpellCastEvent { @@ -454,36 +462,41 @@ impl SpellCastEvent { /// ~20-field literal + 4× `grid_of` copy-paste this used to require at every call site (perf catalog /// audit, 2026-08-06): a call site now overrides only the 2-4 fields that carry real signal, via /// struct-update syntax, e.g. - /// `SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal(ctx, caster_guid, spell_id) }`. + /// `SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal(ctx, caster_guid, spell_id, + /// SpellCastEventKind::Interrupt) }`. /// /// Use [`Self::signal_at`] instead when the caster's live [`WorldEntity`] is already in hand — it /// skips this lookup entirely (a landed swing carrying a seal proc + a queued strike used to pay the /// `game_world_entity` PK lookup up to twelve times over for what is, in every case, the SAME row). - pub(crate) fn signal(ctx: &ReducerContext, caster_guid: u64, spell_id: u32) -> Self { - let (map_id, instance_id, grid_x, grid_y) = crate::helpers::grid_of(ctx, caster_guid); + pub(crate) fn signal( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + kind: SpellCastEventKind, + ) -> Self { Self::signal_addr( ctx, caster_guid, spell_id, - map_id, - instance_id, - grid_x, - grid_y, + crate::helpers::grid_of(ctx, caster_guid), + kind, ) } /// Same baseline as [`Self::signal`], stamped from an already-fetched `caster` entity — zero /// `game_world_entity` lookups. - pub(crate) fn signal_at(ctx: &ReducerContext, caster: &WorldEntity, spell_id: u32) -> Self { - let (map_id, instance_id, grid_x, grid_y) = crate::helpers::entity_addr(caster); + pub(crate) fn signal_at( + ctx: &ReducerContext, + caster: &WorldEntity, + spell_id: u32, + kind: SpellCastEventKind, + ) -> Self { Self::signal_addr( ctx, caster.guid, spell_id, - map_id, - instance_id, - grid_x, - grid_y, + crate::helpers::entity_addr(caster), + kind, ) } @@ -491,11 +504,10 @@ impl SpellCastEvent { ctx: &ReducerContext, caster_guid: u64, spell_id: u32, - map_id: u32, - instance_id: u64, - grid_x: i32, - grid_y: i32, + address: (u32, u64, i32, i32), + kind: SpellCastEventKind, ) -> Self { + let (map_id, instance_id, grid_x, grid_y) = address; Self { id: 0, caster_guid, @@ -521,6 +533,7 @@ impl SpellCastEvent { grid_x, grid_y, failure_reason: CAST_FAIL_NONE, + kind: kind as u8, } } }