using System;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Spells;
namespace AcDream.Core.Net;
///
/// Central registration point that wires every parsed GameEvent from
/// into the appropriate Core state
/// class (, ,
/// , ).
///
///
/// Call once at startup (or on reconnect) passing the session's
/// dispatcher + the state instances you want to feed. The wiring is
/// additive — if you want to add a custom handler for a specific
/// event, register it AFTER this helper so it overrides the default.
///
///
///
/// This is the piece that makes Phase F.1's dispatcher go from "a
/// thing that routes opcodes" to "a thing that actually populates
/// client state so the UI can redraw". Without this glue every
/// dispatcher handler had to be written by hand at each call site.
///
///
public static class GameEventWiring
{
public static void WireAll(
GameEventDispatcher dispatcher,
ItemRepository items,
CombatState combat,
Spellbook spellbook,
ChatLog chat,
LocalPlayerState? localPlayer = null,
TurbineChatState? turbineChat = null,
// K-fix7 (2026-04-26): server-sent skill update callback. Fires
// whenever PlayerDescription's skill table arrives with Run (24)
// or Jump (22) entries — we only care about those two for
// movement physics. Caller (GameWindow) plumbs this into the
// active PlayerMovementController + caches it for the next
// EnterPlayerModeNow construction.
//
// K-fix13 (2026-04-26): the wire's `init` field is ONLY
// InitLevel (training tier from chargen, per ACE
// GameEventPlayerDescription.cs:317 "init_level, for
// training/specialized bonus"). The AttributeFormula
// contribution (Strength/Quickness/etc-derived) is computed
// by ACE at runtime via portal.dat's SkillTable + attribute
// currents. Without it our totals undershoot the real
// Current skill by 50-100 points for movement skills, which
// is why jumps looked too short. The optional
// resolveSkillFormulaBonus callback lets the caller
// (GameWindow) plug in the AttributeFormula contribution
// using its cached SkillTable + attribute currents — when
// present, total skill = formulaBonus + init + ranks
// (matching ACE's CreatureSkill.Current minus
// augs/multipliers/vitae which we still don't model).
Action? onSkillsUpdated = null,
Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
{
var p = GameEvents.ParseChannelBroadcast(e.Payload.Span);
if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message);
});
dispatcher.Register(GameEventType.Tell, e =>
{
var p = GameEvents.ParseTell(e.Payload.Span);
if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid);
});
dispatcher.Register(GameEventType.CommunicationTransientString, e =>
{
var p = GameEvents.ParseTransient(e.Payload.Span);
if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType);
});
dispatcher.Register(GameEventType.PopupString, e =>
{
var s = GameEvents.ParsePopupString(e.Payload.Span);
if (s is not null) chat.OnPopup(s);
});
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
// Phase I.6: arrives once at login (and after chat-server reconnect)
// listing the per-session room ids assigned to General / Trade /
// LFG / Roleplay / Society / Olthoi (and the optional Allegiance
// Turbine room). Without this the TurbineChat outbound path stays
// disabled and the chat panel falls back to the legacy ChatChannel
// GameAction. See holtburger client/messages.rs:220-223 for the
// server-message handling pattern.
if (turbineChat is not null)
{
dispatcher.Register(GameEventType.SetTurbineChatChannels, e =>
{
var p = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (p is null) return;
turbineChat.OnChannelsReceived(
allegianceRoom: p.Value.AllegianceRoom,
generalRoom: p.Value.GeneralRoom,
tradeRoom: p.Value.TradeRoom,
lfgRoom: p.Value.LfgRoom,
roleplayRoom: p.Value.RoleplayRoom,
olthoiRoom: p.Value.OlthoiRoom,
societyRoom: p.Value.SocietyRoom,
societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom,
societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom,
societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom);
// Diagnostic: confirm the channel ids landed. Without
// this print there's no easy way to tell from the live
// log whether ACE actually sent 0x0295 or whether
// outbound /g /trade /lfg are silently falling back to
// the (broken) legacy ChatChannel path.
Console.WriteLine(
$"chat: SetTurbineChatChannels parsed enabled={turbineChat.Enabled} " +
$"general=0x{p.Value.GeneralRoom:X8} trade=0x{p.Value.TradeRoom:X8} " +
$"lfg=0x{p.Value.LfgRoom:X8} roleplay=0x{p.Value.RoleplayRoom:X8} " +
$"society=0x{p.Value.SocietyRoom:X8} olthoi=0x{p.Value.OlthoiRoom:X8} " +
$"allegiance=0x{p.Value.AllegianceRoom:X8}");
});
}
// ── Errors ───────────────────────────────────────────────
// Phase I.5: WeenieError + WeenieErrorWithString parsers existed
// (GameEvents.ParseWeenieError(WithString)) but were never registered.
// The server fires these for game-logic failures: "not enough mana",
// "can't pick that up", "your spell fizzled". Routed to chat.
dispatcher.Register(GameEventType.WeenieError, e =>
{
var code = GameEvents.ParseWeenieError(e.Payload.Span);
if (code is not null) chat.OnWeenieError(code.Value, param: null);
});
dispatcher.Register(GameEventType.WeenieErrorWithString, e =>
{
var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span);
if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation);
});
// ── Combat ────────────────────────────────────────────────
dispatcher.Register(GameEventType.UpdateHealth, e =>
{
var p = GameEvents.ParseUpdateHealth(e.Payload.Span);
if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent);
});
dispatcher.Register(GameEventType.VictimNotification, e =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
});
dispatcher.Register(GameEventType.DefenderNotification, e =>
{
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
if (p is not null) combat.OnDefenderNotification(
p.Value.AttackerName, 0u, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
});
dispatcher.Register(GameEventType.AttackerNotification, e =>
{
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
if (p is not null) combat.OnAttackerNotification(
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
});
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
{
var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span);
if (name is not null) combat.OnEvasionAttackerNotification(name);
});
dispatcher.Register(GameEventType.EvasionDefenderNotification, e =>
{
var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span);
if (name is not null) combat.OnEvasionDefenderNotification(name);
});
dispatcher.Register(GameEventType.AttackDone, e =>
{
var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
});
dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
{
if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
combat.OnCombatCommenceAttack();
});
dispatcher.Register(GameEventType.KillerNotification, e =>
{
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
});
// ── Spells ────────────────────────────────────────────────
dispatcher.Register(GameEventType.MagicUpdateSpell, e =>
{
var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span);
if (spellId is not null) spellbook.OnSpellLearned(spellId.Value);
});
dispatcher.Register(GameEventType.MagicRemoveSpell, e =>
{
var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span);
if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value);
});
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is not null) items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid,
newSlot: (int)p.Value.Placement);
});
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
// Merge parsed properties into the item if we know about it.
if (items.GetItem(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
// Spellbook from appraise: for caster items / scrolls this is
// the cast-on-use list. The local player's full learned
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
});
// ── Player ────────────────────────────────────────────────
// PlayerDescription (0x0013) — full local-player snapshot at
// login. Distinct wire format from IdentifyObjectResponse
// (0x00C9): hand-written body with property hashtables,
// vector-flag-gated blocks, attribute block (where vitals 7/8/9
// carry their absolute current values), skills, spells, and a
// long trailer of options + inventory. See
// PlayerDescriptionParser for the full layout reference (mirrors
// holtburger events.rs:220-625).
//
// Two outputs from each parsed PlayerDescription:
// 1. LocalPlayerState absorbs vital ids 7/8/9 (Health/Stam/Mana).
// This is the ONLY way these arrive at login; PrivateUpdateVital
// delta opcodes only fire on rank-up / Enlightenment / admin
// changes — not initial sync.
// 2. Spellbook absorbs the learned spell list — for the local
// player this is the authoritative source (the per-item
// SpellBook flag in IdentifyObjectResponse is for caster
// items / scrolls only).
bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
dispatcher.Register(GameEventType.PlayerDescription, e =>
{
var p = PlayerDescriptionParser.TryParse(e.Payload.Span);
if (dumpPd)
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
// K-fix13 (2026-04-26): build attrId → current map while
// iterating attributes so the skill-formula resolver below
// can apply (attr1.current * mult1 + attr2.current * mult2)
// / divisor + additive. "current" here = ranks + start
// (the formula-relevant attribute level pre-augs / pre-buffs).
// Built unconditionally (not inside the localPlayer guard)
// because the skill-formula resolver needs it even if no
// LocalPlayerState is wired.
var attrCurrents = new Dictionary();
foreach (var attr in p.Value.Attributes)
{
// PD-attr ids 1-6 are primary attributes (Str / End
// / Coord / Quick / Focus / Self). 7/8/9 are vitals.
if (attr.AtType >= 1 && attr.AtType <= 6)
attrCurrents[attr.AtType] = attr.Ranks + attr.Start;
}
if (localPlayer is not null)
{
foreach (var attr in p.Value.Attributes)
{
if (attr.Current is uint cur)
{
// Vital entry (id 7/8/9) — has absolute current.
if (dumpPd)
Console.WriteLine($"vitals: PD-vital id={attr.AtType} ranks={attr.Ranks} start={attr.Start} cur={cur}");
localPlayer.OnVitalUpdate(
vitalId: attr.AtType,
ranks: attr.Ranks,
start: attr.Start,
xp: attr.Xp,
current: cur);
}
else
{
// Primary attribute (id 1..6) — Endurance+Self feed
// the vital max formula (Endurance/2 for Health,
// Endurance for Stamina, Self for Mana).
if (dumpPd)
Console.WriteLine($"vitals: PD-attr id={attr.AtType} ranks={attr.Ranks} start={attr.Start}");
localPlayer.OnAttributeUpdate(
atType: attr.AtType,
ranks: attr.Ranks,
start: attr.Start,
xp: attr.Xp);
}
}
}
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// K-fix7 (2026-04-26): push Run + Jump skill values to the
// PlayerMovementController so the runRate / jump-arc formulas
// use the SERVER's authoritative skill instead of our
// hardcoded ACDREAM_*_SKILL defaults. ACE Skill enum
// ordinals (Skill.cs:11-37): Jump = 22, Run = 24. The
// SkillEntry.Init field is the attribute-derived initial
// component; .Ranks is XP-bought additions. Their sum is
// the closest we get to ACE's CreatureSkill.Current short
// of porting the full Aug/Multiplier/Vitae chain.
if (onSkillsUpdated is not null)
{
int runSkill = -1;
int jumpSkill = -1;
foreach (var s in p.Value.Skills)
{
if (s.SkillId != 22u && s.SkillId != 24u) continue;
// K-fix13: total = AttributeFormula(skill, attrs)
// + InitLevel (s.Init from wire)
// + Ranks (s.Ranks from wire)
// matches ACE CreatureSkill.Current minus
// augs/multipliers/vitae. The attribute-formula
// contribution is the dominant term for movement
// skills (typically 50-100 points) and was being
// dropped pre-fix13 — that's the root cause of
// jumps being too short relative to retail.
uint formulaBonus = resolveSkillFormulaBonus is not null
? resolveSkillFormulaBonus(s.SkillId, attrCurrents)
: 0u;
int total = (int)(formulaBonus + s.Init + s.Ranks);
if (s.SkillId == 24u) runSkill = total;
else if (s.SkillId == 22u) jumpSkill = total;
if (dumpPd)
Console.WriteLine(
$"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}");
}
if (runSkill >= 0 || jumpSkill >= 0)
onSkillsUpdated(runSkill, jumpSkill);
}
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// Issue #13 — register inventory entries with ItemRepository so
// panels (inventory, paperdoll, hotbars) light up after login.
// Equipped entries share the same ObjectId as inventory entries
// (an equipped item is also in inventory) — register both, but
// the equipped record carries the slot mask which we surface via
// MoveItem so paperdoll can render.
foreach (var inv in p.Value.Inventory)
{
if (items.GetItem(inv.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = inv.Guid,
WeenieClassId = inv.ContainerType,
});
}
}
foreach (var eq in p.Value.Equipped)
{
if (items.GetItem(eq.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = eq.Guid,
WeenieClassId = 0,
});
}
// Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
items.MoveItem(
itemId: eq.Guid,
newContainerId: 0,
newSlot: -1,
newEquipLocation: (EquipMask)eq.EquipLocation);
}
});
}
}