refactor(net): own live session routing
This commit is contained in:
parent
707e606e35
commit
961bdd07b7
12 changed files with 1645 additions and 543 deletions
|
|
@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private bool DevToolsEnabled => _options.DevTools;
|
||||
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
|
||||
|
||||
// Phase I.3 — real ICommandBus for live sessions. Constructed when
|
||||
// the live session spins up (so SendChatCmd handlers can close over
|
||||
// _liveSession + Chat). Null when offline; PanelContext then falls
|
||||
// back to NullCommandBus.Instance.
|
||||
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
|
||||
|
||||
// Phase I.7 — bridges CombatState's typed events into ChatLog as
|
||||
// retail-faithful "You hit ..." / "... evaded your attack." lines.
|
||||
// Disposable; lives for the duration of the live session.
|
||||
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
|
||||
// Slice 3: exact session-generation owners. The command router is itself
|
||||
// an ICommandBus and becomes inert before the displaced socket closes.
|
||||
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
|
||||
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
|
||||
|
||||
// Phase G.1-G.2 world lighting/time state.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||
|
|
@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||
retailChatVm,
|
||||
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||
radarSnapshotProvider.BuildSnapshot,
|
||||
|
|
@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||||
if (_liveSession is null)
|
||||
{
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
|
||||
{
|
||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
}
|
||||
ClearInboundEntityState();
|
||||
return;
|
||||
}
|
||||
|
|
@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||
Combat.Clear();
|
||||
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
|
||||
_liveSessionCommands?.Activate();
|
||||
|
||||
_activeToonKey = chosen.Name;
|
||||
_retailUiRuntime?.RestoreLayout();
|
||||
|
|
@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"live: session failed: {ex.Message}");
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
}
|
||||
ClearInboundEntityState();
|
||||
}
|
||||
}
|
||||
|
|
@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||||
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
|
||||
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
|
||||
/// is constructed and BEFORE any network I/O.
|
||||
/// Composes the exact inbound and outbound owners for one live session.
|
||||
/// Registration completes before Connect; outbound commands remain inert
|
||||
/// until EnterWorld succeeds.
|
||||
/// </summary>
|
||||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
_liveSession = session;
|
||||
// D.5.4: wire non-lifecycle object quality/property updates. The live
|
||||
// handlers below apply Create/Delete to both projections only after
|
||||
// their canonical timestamp decision.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(
|
||||
session, Objects, () => _playerServerGuid, LocalPlayer);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += spawn =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
spawn,
|
||||
static (window, value) => window.OnLiveEntitySpawned(value));
|
||||
_liveSession.EntityDeleted += deletion =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
deletion,
|
||||
static (window, value) => window.OnLiveEntityDeleted(value));
|
||||
_liveSession.EntityPickedUp += pickup =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
pickup,
|
||||
static (window, value) => window.OnLiveEntityPickedUp(value));
|
||||
_liveSession.MotionUpdated += motion =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
motion,
|
||||
static (window, value) => window.OnLiveMotionUpdated(value));
|
||||
_liveSession.PositionUpdated += position =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
position,
|
||||
static (window, value) => window.OnLivePositionUpdated(value));
|
||||
_liveSession.VectorUpdated += vector =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
vector,
|
||||
static (window, value) => window.OnLiveVectorUpdated(value));
|
||||
_liveSession.StateUpdated += state =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
state,
|
||||
static (window, value) => window.OnLiveStateUpdated(value));
|
||||
_liveSession.ParentUpdated += parent =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
parent,
|
||||
static (window, value) => window.OnLiveParentUpdated(value));
|
||||
_liveSession.TeleportStarted += teleport =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
teleport,
|
||||
static (window, value) => window.OnTeleportStarted(value));
|
||||
_liveSession.AppearanceUpdated += appearance =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
appearance,
|
||||
static (window, value) => window.OnLiveAppearanceUpdated(value));
|
||||
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
|
||||
throw new InvalidOperationException("live-session routing is already bound");
|
||||
|
||||
// Phase 6c — PlayScript (0xF754) arrives from the server as
|
||||
// a (guid, scriptId) pair. Resolve the guid's current world
|
||||
// position and feed the PhysicsScript runner; it schedules
|
||||
// the script's hooks (particle spawns, sound cues, light
|
||||
// toggles) at their StartTime offsets. This is the channel
|
||||
// retail uses for spell casts, combat flinches, emote
|
||||
// gestures, AND — per Agent #5 research — lightning
|
||||
// flashes during stormy weather.
|
||||
_liveSession.PlayPhysicsScriptReceived += script =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
script,
|
||||
static (window, value) => window.OnPlayScriptReceived(value));
|
||||
_liveSession.PlayPhysicsScriptTypeReceived += message =>
|
||||
_inboundEntityEvents.Run(
|
||||
this,
|
||||
message,
|
||||
static (window, value) => window._entityEffects?.HandleTyped(value));
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
if (_characterSheetProvider is not null && _dats is not null)
|
||||
{
|
||||
_characterSheetProvider.SkillTable = skillTable;
|
||||
_characterSheetProvider.ExperienceTable =
|
||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||
_dats,
|
||||
Console.WriteLine);
|
||||
}
|
||||
|
||||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
||||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
||||
// sound types (0x65..0x7B) play a one-shot audio cue.
|
||||
// Lightning flashes arrive as a PAIRED PlayScript (the
|
||||
// visual) + AdminEnvirons ThunderXSound (the audio) — both
|
||||
// are handled here and in OnPlayScriptReceived respectively.
|
||||
_liveSession.EnvironChanged += OnEnvironChanged;
|
||||
|
||||
// Phase G.1: keep the client's day/night clock in sync with
|
||||
// server time. Fires once from ConnectRequest (initial seed)
|
||||
// and repeatedly on TimeSync-flagged packets.
|
||||
// Phase 3a: also re-roll the active DayGroup if the Dereth-day
|
||||
// index changed — retail rolls one weather preset per server
|
||||
// day (r12 §11), deterministic from the day index so retail
|
||||
// and acdream converge without a wire message.
|
||||
_liveSession.ServerTimeUpdated += ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
};
|
||||
|
||||
// Phase F.1-H.1: wire every parsed GameEvent into the right
|
||||
// Core state class (chat, combat, spellbook, items). After
|
||||
// this one call, server-sent ChannelBroadcast / damage
|
||||
// notifications / spell learns / wield events all update
|
||||
// the corresponding client-side state without further glue.
|
||||
// K-fix13 (2026-04-26): cache portal.dat's SkillTable so the
|
||||
// skill-formula resolver can apply the AttributeFormula
|
||||
// contribution. Without this, our wire-derived "skill"
|
||||
// value was missing the dominant attribute-derived term
|
||||
// and jumps undershot retail by ~30 % at typical
|
||||
// attribute levels.
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
if (_characterSheetProvider is not null && _dats is not null)
|
||||
{
|
||||
_characterSheetProvider.SkillTable = skillTable;
|
||||
_characterSheetProvider.ExperienceTable =
|
||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||
_dats, Console.WriteLine);
|
||||
}
|
||||
|
||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
||||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
||||
TurbineChat,
|
||||
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
|
||||
{
|
||||
// ACE GetFormula (AttributeFormula.cs:55-): when
|
||||
// formula.X (Attribute1Multiplier) is 0, the formula
|
||||
// is "no attribute contribution" and the function
|
||||
// returns 0. Otherwise:
|
||||
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
|
||||
if (skillTable?.Skills is null) return 0u;
|
||||
if (!skillTable.Skills.TryGetValue(
|
||||
(DatReaderWriter.Enums.SkillId)skillId, out var skillBase))
|
||||
return 0u;
|
||||
var f = skillBase.Formula;
|
||||
if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u;
|
||||
attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1);
|
||||
attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2);
|
||||
long num = (long)a1 * f.Attribute1Multiplier
|
||||
+ (long)a2 * f.Attribute2Multiplier;
|
||||
long bonus = num / f.Divisor + f.AdditiveBonus;
|
||||
return bonus < 0 ? 0u : (uint)bonus;
|
||||
},
|
||||
onSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
// K-fix7 (2026-04-26): cache the latest server-sent
|
||||
// Run / Jump skill values so the next
|
||||
// EnterPlayerModeNow can hand them to the new
|
||||
// PlayerMovementController. Push immediately too,
|
||||
// so a PD that arrives WHILE player mode is active
|
||||
// (re-equip / log-in mid-session) updates the live
|
||||
// controller. -1 from the wiring means "skill not
|
||||
// present in this PD" — keep the previous cached
|
||||
// value rather than overwriting with -1.
|
||||
if (runSkill >= 0) _lastSeenRunSkill = runSkill;
|
||||
if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill;
|
||||
if (_playerController is not null
|
||||
&& _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
||||
try
|
||||
{
|
||||
events = new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
CreateLiveEntitySessionSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
_playerController.SetCharacterSkills(
|
||||
_lastSeenRunSkill, _lastSeenJumpSkill);
|
||||
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
|
||||
}
|
||||
},
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error =>
|
||||
{
|
||||
_externalContainers.ApplyUseDone(error);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||
onConfirmationDone: done =>
|
||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||
friends: Friends,
|
||||
squelch: Squelch,
|
||||
onDesiredComponents: components => DesiredComponents = components,
|
||||
onCharacterOptions: (options1, _) =>
|
||||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
clientTime: ClientTimerNow,
|
||||
externalContainers: _externalContainers);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
// the unified ChatLog. The translator owns the wording
|
||||
// (templates ported from holtburger chat.rs:221-308); the
|
||||
// panel renders combat entries via TextColored.
|
||||
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
|
||||
|
||||
// Phase H.1: feed inbound HearSpeech into the chat log.
|
||||
_liveSession.SpeechHeard += speech =>
|
||||
Chat.OnLocalSpeech(
|
||||
sender: speech.SenderName,
|
||||
text: speech.Text,
|
||||
senderGuid: speech.SenderGuid,
|
||||
isRanged: speech.IsRanged);
|
||||
|
||||
// Phase I.6: feed inbound TurbineChat events into the chat log.
|
||||
// The Response variant is fire-and-forget (server-side ack);
|
||||
// EventSendToRoom is a real chat message broadcast to a room.
|
||||
// Phase J: ACE's GameMessageSystemChat (used for the login
|
||||
// banner "Welcome to Asheron's Call ... type @acehelp" and
|
||||
// for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage,
|
||||
// parsed in I.5 but never wired. Surface it as a System
|
||||
// chat line so the welcome banner appears + future server
|
||||
// pushes (announcements, command responses) show.
|
||||
_liveSession.ServerMessageReceived += sm =>
|
||||
Chat.OnSystemMessage(sm.Message, sm.ChatType);
|
||||
|
||||
// Phase I.5 + J: emotes already had ChatLog adapters; wire
|
||||
// their session events here so they actually reach chat.
|
||||
_liveSession.EmoteHeard += emote =>
|
||||
Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
||||
_liveSession.SoulEmoteHeard += emote =>
|
||||
Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
||||
_liveSession.PlayerKilledReceived += pk =>
|
||||
Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid);
|
||||
|
||||
_liveSession.TurbineChatReceived += parsed =>
|
||||
{
|
||||
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
|
||||
{
|
||||
// Pass the friendly channel name out-of-band via
|
||||
// ChatLog.OnChannelBroadcast's channelName param so
|
||||
// ChatVM.FormatEntry can render the retail-style
|
||||
// "[Trade] +Acdream says, \"hello\"" without us
|
||||
// mangling the payload text.
|
||||
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
|
||||
Chat.OnChannelBroadcast(
|
||||
channelId: ev.RoomId,
|
||||
sender: ev.SenderName,
|
||||
text: ev.Message,
|
||||
channelName: label);
|
||||
}
|
||||
// Response (server ack of an outbound RequestSendToRoomById)
|
||||
// and Unknown payloads are intentionally not surfaced —
|
||||
// the inbound EventSendToRoom for our own message acts as
|
||||
// the canonical echo.
|
||||
};
|
||||
|
||||
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
|
||||
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
||||
// plus a local echo into ChatLog so the player sees their own
|
||||
// message immediately. Closes over _liveSession + Chat so this
|
||||
// wiring only exists for the lifetime of the live session.
|
||||
// Step 2: capture the non-null `session` parameter rather than
|
||||
// the nullable _liveSession field. Semantically identical (the
|
||||
// field WAS set to `session` at the top of WireLiveSessionEvents)
|
||||
// but the compiler can prove non-null for the lambda body.
|
||||
var liveSession = session;
|
||||
var chat = Chat;
|
||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||||
var turbineChat = TurbineChat;
|
||||
uint playerGuid = _playerServerGuid;
|
||||
var clientCommandController = new AcDream.App.UI.ClientCommandController(
|
||||
new AcDream.App.UI.ClientCommandController.Bindings(
|
||||
TeleportToLifestone: liveSession.SendTeleportToLifestone,
|
||||
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
|
||||
TeleportToPkArena: liveSession.SendTeleportToPkArena,
|
||||
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
|
||||
TeleportToHouse: liveSession.SendTeleportToHouse,
|
||||
TeleportToMansion: liveSession.SendTeleportToMansion,
|
||||
QueryAge: liveSession.SendQueryAge,
|
||||
QueryBirth: liveSession.SendQueryBirth,
|
||||
ToggleFrameRate: ToggleRetailFrameRate,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () =>
|
||||
LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ShowConfirmation(message, completed),
|
||||
Suicide: liveSession.SendSuicide,
|
||||
ClearChat: _ => chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(liveSession, away),
|
||||
SetAwayMessage: liveSession.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: liveSession.SendDisplayConsent,
|
||||
ClearConsent: liveSession.SendClearConsent,
|
||||
RemoveConsent: liveSession.SendRemoveConsent,
|
||||
SendEmote: liveSession.SendEmote,
|
||||
Friends: Friends,
|
||||
AddFriend: liveSession.SendAddFriend,
|
||||
RemoveFriend: liveSession.SendRemoveFriend,
|
||||
ClearFriends: liveSession.SendClearFriends,
|
||||
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
|
||||
Squelch: Squelch,
|
||||
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
liveSession.SendClearDesiredComponents();
|
||||
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }));
|
||||
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
||||
clientCommandController.Execute);
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cmd.Text))
|
||||
liveSession.SendTalk(cmd.Text);
|
||||
});
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||||
switch (cmd.Channel)
|
||||
{
|
||||
case AcDream.UI.Abstractions.ChatChannelKind.Say:
|
||||
// Phase J: drop optimistic /say echo. ACE's
|
||||
// HandleActionTalk broadcasts a HearSpeech back
|
||||
// to the sender too, and ChatLog.OnLocalSpeech
|
||||
// detects own-guid match to render it as
|
||||
// "You say, ...". Optimistic-echoing here
|
||||
// doubled the line. ALSO: don't echo "@xxx"
|
||||
// server-side admin commands — ACE consumes
|
||||
// them silently and replies via SystemChat.
|
||||
liveSession.SendTalk(cmd.Text);
|
||||
break;
|
||||
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
|
||||
if (string.IsNullOrEmpty(cmd.TargetName)) return;
|
||||
liveSession.SendTell(cmd.TargetName, cmd.Text);
|
||||
chat.OnSelfSent(
|
||||
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
|
||||
targetOrChannel: cmd.TargetName);
|
||||
break;
|
||||
default:
|
||||
// Phase I.6: try TurbineChat first for the global
|
||||
// community channels (General/Trade/LFG/Roleplay/
|
||||
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
|
||||
// Allegiance is double-routed: try TurbineChat first
|
||||
// (when the player has a Turbine allegiance room) and
|
||||
// fall back to the legacy 0x0147 ChatChannel.
|
||||
//
|
||||
// We do NOT optimistic-echo channels: ACE's
|
||||
// TurbineChatHandler broadcasts EventSendToRoom back
|
||||
// to the sender too, so we always get the canonical
|
||||
// echo from the server. Optimistic-echoing here
|
||||
// double-prints the message (one as "[Trade] hello"
|
||||
// from us, one as "[Trade] +Acdream says, \"hello\""
|
||||
// from the server). Trust the server.
|
||||
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
|
||||
if (turbine is not null)
|
||||
{
|
||||
uint cookie = turbineChat.NextContextId();
|
||||
// Use the live player guid if it's been captured;
|
||||
// otherwise 0 (server treats unknown sender_id
|
||||
// gracefully — the cookie is what we care about).
|
||||
uint senderGuid = _playerServerGuid != 0u
|
||||
? _playerServerGuid
|
||||
: playerGuid;
|
||||
Console.WriteLine(
|
||||
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
|
||||
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
|
||||
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}");
|
||||
liveSession.SendTurbineChatTo(
|
||||
roomId: turbine.Value.RoomId,
|
||||
chatType: turbine.Value.ChatType,
|
||||
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid: senderGuid,
|
||||
text: cmd.Text,
|
||||
cookie: cookie);
|
||||
// No optimistic echo: server EventSendToRoom
|
||||
// broadcast comes back with sender="+Acdream"
|
||||
// and is rendered by ChatVM as
|
||||
// "[Trade] +Acdream says, \"hello\"".
|
||||
break;
|
||||
}
|
||||
|
||||
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
|
||||
if (resolved is null)
|
||||
{
|
||||
// Diagnostic: the user picked a channel kind that
|
||||
// (a) isn't a Turbine channel TurbineChatState
|
||||
// knows about and (b) has no legacy ChatChannel
|
||||
// mapping. Most common cause: TurbineChat hasn't
|
||||
// been enabled yet (server didn't send 0x0295)
|
||||
// and the kind is General/Trade/LFG/etc.
|
||||
Console.WriteLine(
|
||||
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
|
||||
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
|
||||
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
|
||||
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
||||
// Legacy channels (Fellowship / Allegiance / Patron /
|
||||
// Monarch / Vassals / CoVassals) — keep the optimistic
|
||||
// echo because legacy ChatChannel does NOT always
|
||||
// broadcast back to the sender. ChannelName is the
|
||||
// friendly display name so ChatVM renders it as
|
||||
// "[Fellowship] +Acdream says, \"hello\"".
|
||||
chat.OnSelfSent(
|
||||
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
|
||||
targetOrChannel: resolved.Value.DisplayName);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
|
||||
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
|
||||
_liveSession.VitalUpdated += v =>
|
||||
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
|
||||
_liveSession.VitalCurrentUpdated += v =>
|
||||
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
|
||||
commands = new AcDream.App.Net.LiveSessionCommandRouter(
|
||||
CreateLiveSessionCommandBindings(session));
|
||||
_liveSessionEvents = events;
|
||||
_liveSessionCommands = commands;
|
||||
}
|
||||
catch
|
||||
{
|
||||
commands?.Dispose();
|
||||
events?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
|
||||
Spawned: spawn => _inboundEntityEvents.Run(
|
||||
this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)),
|
||||
Deleted: deletion => _inboundEntityEvents.Run(
|
||||
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
|
||||
PickedUp: pickup => _inboundEntityEvents.Run(
|
||||
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
|
||||
MotionUpdated: motion => _inboundEntityEvents.Run(
|
||||
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
|
||||
PositionUpdated: position => _inboundEntityEvents.Run(
|
||||
this, position, static (window, value) => window.OnLivePositionUpdated(value)),
|
||||
VectorUpdated: vector => _inboundEntityEvents.Run(
|
||||
this, vector, static (window, value) => window.OnLiveVectorUpdated(value)),
|
||||
StateUpdated: state => _inboundEntityEvents.Run(
|
||||
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
|
||||
ParentUpdated: parent => _inboundEntityEvents.Run(
|
||||
this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
|
||||
TeleportStarted: teleport => _inboundEntityEvents.Run(
|
||||
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
|
||||
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
|
||||
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
|
||||
PlayPhysicsScript: script => _inboundEntityEvents.Run(
|
||||
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
|
||||
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
|
||||
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
|
||||
|
||||
private AcDream.App.Net.LiveInventorySessionBindings
|
||||
CreateLiveInventorySessionBindings() => new(
|
||||
Objects,
|
||||
LocalPlayer,
|
||||
PlayerGuid: () => _playerServerGuid,
|
||||
OnShortcuts: list => Shortcuts = list,
|
||||
OnUseDone: error =>
|
||||
{
|
||||
_externalContainers.ApplyUseDone(error);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
ItemMana,
|
||||
OnDesiredComponents: components => DesiredComponents = components,
|
||||
ExternalContainers: _externalContainers);
|
||||
|
||||
private AcDream.App.Net.LiveCharacterSessionBindings
|
||||
CreateLiveCharacterSessionBindings(
|
||||
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
|
||||
Combat,
|
||||
SpellBook,
|
||||
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
|
||||
{
|
||||
// ACE AttributeFormula.GetFormula
|
||||
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
|
||||
// Attribute1Multiplier == 0 means no attribute contribution.
|
||||
// Otherwise the retail data formula is
|
||||
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
|
||||
// Guard a zero divisor because malformed/custom DAT input must not
|
||||
// take down the live-session dispatcher.
|
||||
if (skillTable?.Skills is null)
|
||||
return 0u;
|
||||
if (!skillTable.Skills.TryGetValue(
|
||||
(DatReaderWriter.Enums.SkillId)skillId,
|
||||
out var skillBase))
|
||||
return 0u;
|
||||
|
||||
var formula = skillBase.Formula;
|
||||
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
|
||||
return 0u;
|
||||
|
||||
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
|
||||
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
|
||||
long numerator =
|
||||
(long)attribute1 * formula.Attribute1Multiplier +
|
||||
(long)attribute2 * formula.Attribute2Multiplier;
|
||||
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
|
||||
return bonus < 0 ? 0u : (uint)bonus;
|
||||
},
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
if (runSkill >= 0)
|
||||
_lastSeenRunSkill = runSkill;
|
||||
if (jumpSkill >= 0)
|
||||
_lastSeenJumpSkill = jumpSkill;
|
||||
if (_playerController is not null
|
||||
&& _lastSeenRunSkill >= 0
|
||||
&& _lastSeenJumpSkill >= 0)
|
||||
{
|
||||
_playerController.SetCharacterSkills(
|
||||
_lastSeenRunSkill,
|
||||
_lastSeenJumpSkill);
|
||||
Console.WriteLine(
|
||||
$"player: applied server skills run={_lastSeenRunSkill} " +
|
||||
$"jump={_lastSeenJumpSkill}");
|
||||
}
|
||||
},
|
||||
OnConfirmationRequest: request =>
|
||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||
OnConfirmationDone: done =>
|
||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||
OnCharacterOptions: (options1, _) =>
|
||||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
ClientTime: ClientTimerNow);
|
||||
|
||||
private AcDream.App.Net.LiveSocialSessionBindings
|
||||
CreateLiveSocialSessionBindings() => new(
|
||||
Chat,
|
||||
TurbineChat,
|
||||
Friends,
|
||||
Squelch);
|
||||
|
||||
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
|
||||
AcDream.Core.Net.WorldSession session) => new(
|
||||
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
|
||||
TeleportToLifestone: session.SendTeleportToLifestone,
|
||||
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
||||
TeleportToPkArena: session.SendTeleportToPkArena,
|
||||
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
||||
TeleportToHouse: session.SendTeleportToHouse,
|
||||
TeleportToMansion: session.SendTeleportToMansion,
|
||||
QueryAge: session.SendQueryAge,
|
||||
QueryBirth: session.SendQueryBirth,
|
||||
ToggleFrameRate: ToggleRetailFrameRate,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => Chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ShowConfirmation(message, completed),
|
||||
Suicide: session.SendSuicide,
|
||||
ClearChat: _ => Chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(session, away),
|
||||
SetAwayMessage: session.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: session.SendDisplayConsent,
|
||||
ClearConsent: session.SendClearConsent,
|
||||
RemoveConsent: session.SendRemoveConsent,
|
||||
SendEmote: session.SendEmote,
|
||||
Friends,
|
||||
AddFriend: session.SendAddFriend,
|
||||
RemoveFriend: session.SendRemoveFriend,
|
||||
ClearFriends: session.SendClearFriends,
|
||||
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
||||
Squelch,
|
||||
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
session.SendClearDesiredComponents();
|
||||
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }),
|
||||
Chat,
|
||||
TurbineChat,
|
||||
PlayerGuid: () => _playerServerGuid,
|
||||
SendTalk: session.SendTalk,
|
||||
SendTell: session.SendTell,
|
||||
SendChannel: session.SendChannel,
|
||||
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
|
||||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
|
||||
private void DisposeLiveSessionRouting()
|
||||
{
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
|
||||
_liveSessionCommands = null;
|
||||
_liveSessionEvents = null;
|
||||
try
|
||||
{
|
||||
commands?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
events?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
|
||||
/// mesh refs and register it in IGameState. Called from WorldSession events
|
||||
/// on the main thread (Tick runs in the Silk.NET Update callback).
|
||||
/// </summary>
|
||||
private enum LiveProjectionPurpose
|
||||
{
|
||||
LogicalRegistration,
|
||||
|
|
@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// is up so panel-emitted SendChatCmd actually flows server-ward.
|
||||
// Fall back to NullCommandBus for offline / pre-connect renders.
|
||||
AcDream.UI.Abstractions.ICommandBus bus =
|
||||
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
AcDream.UI.Abstractions.NullCommandBus.Instance;
|
||||
var ctx = new AcDream.UI.Abstractions.PanelContext(
|
||||
(float)deltaSeconds,
|
||||
|
|
@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_magicCatalog = null;
|
||||
}),
|
||||
new("streamer", () => _streamer?.Dispose()),
|
||||
new("combat chat", () => _combatChatTranslator?.Dispose()),
|
||||
new("live session routing", DisposeLiveSessionRouting),
|
||||
new("equipped children", () => _equippedChildRenderer?.Dispose()),
|
||||
new("live session", () =>
|
||||
{
|
||||
|
|
@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_window = null;
|
||||
}
|
||||
|
||||
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
|
||||
/// to a runtime Turbine room. Returned by
|
||||
/// <see cref="ResolveTurbineForKind"/> when the player has access
|
||||
/// to that Turbine channel; null otherwise.
|
||||
/// </summary>
|
||||
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
|
||||
/// runtime Turbine room id + chat-type. Returns null when
|
||||
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
|
||||
/// or the channel has no assigned room (e.g. player not in a society).
|
||||
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
|
||||
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
|
||||
/// </summary>
|
||||
private static TurbineResolution? ResolveTurbineForKind(
|
||||
AcDream.UI.Abstractions.ChatChannelKind kind,
|
||||
AcDream.Core.Chat.TurbineChatState state)
|
||||
{
|
||||
if (!state.Enabled) return null;
|
||||
|
||||
var (room, chatType, name) = kind switch
|
||||
{
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
|
||||
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.General =>
|
||||
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
|
||||
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
|
||||
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
|
||||
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Society =>
|
||||
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
|
||||
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
|
||||
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
|
||||
_ => (0u, 0u, string.Empty),
|
||||
};
|
||||
|
||||
if (room == 0u) return null;
|
||||
return new TurbineResolution(room, chatType, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick a human-readable label for a Turbine room broadcast. Uses
|
||||
/// the chat-type when known (semantic name), falls back to the
|
||||
/// numeric room id for unknown rooms.
|
||||
/// </summary>
|
||||
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
|
||||
{
|
||||
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
|
||||
{
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
|
||||
_ => $"Room 0x{roomId:X8}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
|
||||
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue