refactor(runtime): expose canonical gameplay state

Move character options and movement skills into the Runtime-owned character graph, expose borrowed inventory, character, and social views, and route retained UI state commands through generation-gated typed Runtime contracts. Preserve the existing synchronous wire path while deleting the App-owned option and skill mirrors and extending normalized parity checkpoints.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 09:12:30 +02:00
parent 9d0d9b07e0
commit dcb61efb5a
34 changed files with 2076 additions and 103 deletions

View file

@ -18,6 +18,7 @@ using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
@ -56,7 +57,6 @@ internal sealed record InteractionRetainedUiDependencies(
ILocalPlayerIdentitySource PlayerIdentity,
ILocalPlayerControllerSource PlayerController,
ILocalPlayerModeSource PlayerMode,
PlayerCharacterOptionsState CharacterOptions,
Func<DeferredSelectionViewPlaneSource, SelectionCameraSource>
SelectionCameraFactory,
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
@ -118,6 +118,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
private bool _deactivationStarted;
public DeferredLiveSessionUiAuthority Session { get; } = new();
public DeferredGameRuntimeStateCommands GameRuntime { get; } = new();
public DeferredSelectionUiAuthority Selection { get; } = new();
public DeferredSelectionViewPlaneSource SelectionViewPlane { get; } = new();
public DeferredRadarSnapshotSource Radar { get; } = new();
@ -176,6 +177,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
Radar.Deactivate();
SelectionViewPlane.Deactivate();
Selection.Deactivate();
GameRuntime.Deactivate();
Session.Deactivate();
InventoryContainer.Deactivate();
for (int i = _lateOwnerBindings.Count - 1; i >= 0; i--)
@ -294,7 +296,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
sendGive: (target, item, amount) =>
session.CurrentSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
d.CharacterOptions.DragItemOnPlayerOpensSecureTrade,
d.Character.Options.DragItemOnPlayerOpensSecureTrade,
toast: d.Toast,
readyForInventoryRequest: () => session.IsInWorld,
playerOnGround: () =>
@ -375,15 +377,27 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter,
canSendRaise: () => late.Session.IsInWorld,
canSendRaise: () => late.GameRuntime.IsInWorld,
sendRaiseAttribute: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseAttribute(statId, cost),
late.GameRuntime.Advance(
RuntimeAdvancementKind.Attribute,
statId,
cost),
sendRaiseVital: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseVital(statId, cost),
late.GameRuntime.Advance(
RuntimeAdvancementKind.Vital,
statId,
cost),
sendRaiseSkill: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseSkill(statId, cost),
late.GameRuntime.Advance(
RuntimeAdvancementKind.Skill,
statId,
cost),
sendTrainSkill: (statId, credits) =>
late.Session.CurrentSession?.SendTrainSkill(statId, credits));
late.GameRuntime.Advance(
RuntimeAdvancementKind.TrainSkill,
statId,
credits));
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
MagicRuntime magic = MagicRuntime.Create(
@ -543,16 +557,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) =>
late.Session.CurrentSession?.SendAddSpellFavorite(
spellId,
position,
tab),
late.GameRuntime.AddFavorite(tab, position, spellId),
(tab, spellId) =>
late.Session.CurrentSession?.SendRemoveSpellFavorite(spellId, tab),
filters => late.Session.CurrentSession?.SendSpellbookFilter(filters),
spellId => late.Session.CurrentSession?.SendRemoveSpell(spellId),
late.GameRuntime.RemoveFavorite(tab, spellId),
filters => late.GameRuntime.SetSpellbookFilter(filters),
spellId => late.GameRuntime.ForgetSpell(spellId),
(componentId, amount) =>
late.Session.CurrentSession?.SendSetDesiredComponentLevel(
late.GameRuntime.SetDesiredComponent(
componentId,
amount),
d.ClientTime),
@ -589,8 +600,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Inventory.ItemMana,
d.CombatModeCommands.Toggle,
itemInteraction,
entry => late.Session.CurrentSession?.SendAddShortcut(entry),
index => late.Session.CurrentSession?.SendRemoveShortcut(index),
entry => late.GameRuntime.AddShortcut(entry),
index => late.GameRuntime.RemoveShortcut(index),
d.Selection,
handler => d.Combat.HealthChanged += handler,
handler => d.Combat.HealthChanged -= handler,

View file

@ -9,11 +9,186 @@ using AcDream.App.UI.Testing;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.UI.Abstractions;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
/// <summary>
/// Early retained-UI projection over Slice J's later current-runtime seam.
/// Every call captures the view and command owner together and supplies that
/// exact generation, so a displaced session can never receive the command.
/// </summary>
internal sealed class DeferredGameRuntimeStateCommands
{
private readonly object _gate = new();
private IGameRuntimeView? _view;
private IGameRuntimeCommands? _commands;
private bool _deactivated;
public bool IsInWorld
{
get
{
lock (_gate)
return !_deactivated
&& _view?.Lifecycle.State == RuntimeLifecycleState.InWorld;
}
}
public IDisposable Bind(
IGameRuntimeView view,
IGameRuntimeCommands commands)
{
ArgumentNullException.ThrowIfNull(view);
ArgumentNullException.ThrowIfNull(commands);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_view is not null || _commands is not null)
{
throw new InvalidOperationException(
"The retained-UI game-runtime command seam is already bound.");
}
_view = view;
_commands = commands;
}
return new ExpectedRuntimeBinding(this, view, commands);
}
public RuntimeCommandResult AddShortcut(ShortcutEntry entry) =>
Invoke((commands, generation) => commands.InventoryState.AddShortcut(
generation,
new RuntimeShortcutCommand(
entry.Index,
entry.ObjectId,
entry.SpellId)));
public RuntimeCommandResult RemoveShortcut(uint index)
{
if (index > int.MaxValue)
return CurrentResult(RuntimeCommandStatus.Rejected);
return Invoke((commands, generation) =>
commands.InventoryState.RemoveShortcut(
generation,
(int)index));
}
public RuntimeCommandResult AddFavorite(
int tab,
int position,
uint spellId) =>
Invoke((commands, generation) => commands.Spellbook.AddFavorite(
generation,
tab,
position,
spellId));
public RuntimeCommandResult RemoveFavorite(int tab, uint spellId) =>
Invoke((commands, generation) => commands.Spellbook.RemoveFavorite(
generation,
tab,
spellId));
public RuntimeCommandResult SetSpellbookFilter(uint filters) =>
Invoke((commands, generation) => commands.Spellbook.SetFilter(
generation,
filters));
public RuntimeCommandResult ForgetSpell(uint spellId) =>
Invoke((commands, generation) => commands.Spellbook.ForgetSpell(
generation,
spellId));
public RuntimeCommandResult SetDesiredComponent(
uint componentId,
uint amount) =>
Invoke((commands, generation) =>
commands.Spellbook.SetDesiredComponent(
generation,
componentId,
amount));
public RuntimeCommandResult Advance(
RuntimeAdvancementKind kind,
uint statId,
ulong cost) =>
Invoke((commands, generation) => commands.Character.Advance(
generation,
new RuntimeAdvancementCommand(kind, statId, cost)));
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_view = null;
_commands = null;
}
}
private RuntimeCommandResult Invoke(
Func<
IGameRuntimeCommands,
RuntimeGenerationToken,
RuntimeCommandResult> invoke)
{
IGameRuntimeCommands commands;
RuntimeGenerationToken generation;
lock (_gate)
{
if (_deactivated || _view is null || _commands is null)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
_view?.Generation ?? default);
}
commands = _commands;
generation = _view.Generation;
}
return invoke(commands, generation);
}
private RuntimeCommandResult CurrentResult(RuntimeCommandStatus status)
{
lock (_gate)
{
return new RuntimeCommandResult(
status,
_view?.Generation ?? default);
}
}
private void Release(
IGameRuntimeView expectedView,
IGameRuntimeCommands expectedCommands)
{
lock (_gate)
{
if (!ReferenceEquals(_view, expectedView)
|| !ReferenceEquals(_commands, expectedCommands))
{
return;
}
_view = null;
_commands = null;
}
}
private sealed class ExpectedRuntimeBinding(
DeferredGameRuntimeStateCommands owner,
IGameRuntimeView view,
IGameRuntimeCommands commands)
: IDisposable
{
private DeferredGameRuntimeStateCommands? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Release(view, commands);
}
}
/// <summary>
/// Early UI view over the later live-session owner. A binding lease clears only
/// the exact owner it installed, so rollback cannot withdraw a replacement.
@ -394,19 +569,6 @@ internal sealed class DeferredInventoryContainerSource
}
}
internal sealed class PlayerCharacterOptionsState
{
public PlayerDescriptionParser.CharacterOptions1 Options { get; set; } =
PlayerDescriptionParser.CharacterOptions1.Default;
public bool DragItemOnPlayerOpensSecureTrade =>
(Options
& PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0;
public void Reset() => Options = PlayerDescriptionParser.CharacterOptions1.Default;
}
/// <summary>
/// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in
/// Phase 7. Calls remain inert and diagnostic until that exact owner binds.

View file

@ -68,10 +68,8 @@ internal sealed record SessionPlayerDependencies(
LiveWorldOriginState WorldOrigin,
WorldRenderRangeState RenderRange,
LocalPlayerShadowState PlayerShadow,
LocalPlayerSkillState PlayerSkills,
ViewportAspectState ViewportAspect,
PlayerApproachCompletionState PlayerApproachCompletions,
PlayerCharacterOptionsState CharacterOptions,
RuntimeInventoryState Inventory,
PointerPositionState PointerPosition,
DispatcherMovementInputSource MovementInput,
@ -710,7 +708,7 @@ internal sealed class SessionPlayerCompositionPhase
gameplayInput,
liveSessionSource,
d.MovementDiagnostics,
d.PlayerSkills,
d.Character.MovementSkills,
d.ViewportAspect);
if (d.SettingsDevTools.DevTools is { } devTools)
{
@ -796,9 +794,7 @@ internal sealed class SessionPlayerCompositionPhase
new LiveSessionPlayerRuntime(
d.PlayerIdentity,
d.PlayerController,
d.PlayerSkills,
d.WorldOrigin,
d.CharacterOptions),
d.WorldOrigin),
new LiveSessionDomainRuntime(
d.EntityObjects,
d.Character,
@ -876,6 +872,8 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity,
live.LiveEntities,
d.EntityObjects,
d.Inventory,
d.Character,
d.Communication,
d.PlayerController,
worldReveal,
@ -885,6 +883,9 @@ internal sealed class SessionPlayerCompositionPhase
gameplayInput,
combatCommand);
bindings.Adopt("current game runtime adapter", gameRuntime);
bindings.Adopt(
"retained-UI game runtime commands",
interaction.LateBindings.GameRuntime.Bind(gameRuntime, gameRuntime));
var nearbyDiagnostics = new NearbyWorldDiagnosticDumper(
new RuntimeNearbyWorldDiagnosticSource(

View file

@ -140,38 +140,21 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
/// delivery and player-mode construction share this owner so rebuilding the
/// local physics controller cannot fall back to stale defaults.
/// </summary>
internal sealed class LocalPlayerSkillState
internal static class LocalPlayerSkillProjection
{
public int RunSkill { get; private set; } = -1;
public int JumpSkill { get; private set; } = -1;
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
public void Update(
int runSkill,
int jumpSkill,
public static bool ApplyTo(
AcDream.Runtime.Gameplay.RuntimeMovementSkillState skills,
PlayerMovementController? controller)
{
if (runSkill >= 0)
RunSkill = runSkill;
if (jumpSkill >= 0)
JumpSkill = jumpSkill;
ApplyTo(controller);
}
public bool ApplyTo(PlayerMovementController? controller)
{
if (controller is null || !IsComplete)
ArgumentNullException.ThrowIfNull(skills);
AcDream.Runtime.Gameplay.RuntimeMovementSkillSnapshot snapshot =
skills.Snapshot;
if (controller is null || !snapshot.IsComplete)
return false;
controller.SetCharacterSkills(RunSkill, JumpSkill);
controller.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
return true;
}
public void ResetSession()
{
RunSkill = -1;
JumpSkill = -1;
}
}
internal interface IViewportAspectSource

View file

@ -9,6 +9,7 @@ using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Input;
@ -41,7 +42,7 @@ internal sealed class PlayerModeController :
private readonly ILocalPlayerTeleportInputLifetime _input;
private readonly ILiveInWorldSource _session;
private readonly MovementTruthDiagnosticController _movementDiagnostics;
private readonly LocalPlayerSkillState _skills;
private readonly RuntimeMovementSkillState _skills;
private readonly IViewportAspectSource _viewport;
private PlayerModeAutoEntry? _autoEntry;
private IPlayerApproachCompletionSink? _approachLifetime;
@ -67,7 +68,7 @@ internal sealed class PlayerModeController :
ILocalPlayerTeleportInputLifetime input,
ILiveInWorldSource session,
MovementTruthDiagnosticController movementDiagnostics,
LocalPlayerSkillState skills,
RuntimeMovementSkillState skills,
IViewportAspectSource viewport)
{
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
@ -359,7 +360,7 @@ internal sealed class PlayerModeController :
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
};
if (_skills.ApplyTo(controller))
if (LocalPlayerSkillProjection.ApplyTo(_skills, controller))
{
Console.WriteLine(
$"live: {loggingTag} — applied server skills "

View file

@ -1,5 +1,6 @@
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
@ -15,8 +16,64 @@ internal sealed record LiveSessionCommandBindings(
Action<string, string> SendTell,
Action<uint, string> SendChannel,
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
Action<ShortcutEntry> AddShortcut,
Action<uint> RemoveShortcut,
Action<uint, int, int> AddFavorite,
Action<uint, int> RemoveFavorite,
Action<uint> SetSpellbookFilter,
Action<uint> ForgetSpell,
Action<uint, uint> SetDesiredComponent,
Action ClearDesiredComponents,
Action<uint, ulong> RaiseAttribute,
Action<uint, ulong> RaiseVital,
Action<uint, ulong> RaiseSkill,
Action<uint, uint> TrainSkill,
Action<uint> SetCharacterOptions,
Action<string> AddFriend,
Action<uint> RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch,
Action<string>? Log = null);
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
internal readonly record struct AddFavoriteRuntimeCmd(
uint SpellId,
int Position,
int TabIndex);
internal readonly record struct RemoveFavoriteRuntimeCmd(
uint SpellId,
int TabIndex);
internal readonly record struct SetSpellbookFilterRuntimeCmd(uint Filters);
internal readonly record struct ForgetSpellRuntimeCmd(uint SpellId);
internal readonly record struct SetDesiredComponentRuntimeCmd(
uint ComponentId,
uint Amount);
internal readonly record struct ClearDesiredComponentsRuntimeCmd;
internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost);
internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost);
internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost);
internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost);
internal readonly record struct SetCharacterOptionsRuntimeCmd(uint Options);
internal readonly record struct AddFriendRuntimeCmd(string Name);
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
internal readonly record struct ClearFriendsRuntimeCmd;
internal readonly record struct RequestLegacyFriendsRuntimeCmd;
internal readonly record struct ModifyCharacterSquelchRuntimeCmd(
bool Add,
uint CharacterId,
string Name,
uint MessageType);
internal readonly record struct ModifyAccountSquelchRuntimeCmd(
bool Add,
string Name);
internal readonly record struct ModifyGlobalSquelchRuntimeCmd(
bool Add,
uint MessageType);
/// <summary>
/// Owns the command surface for one exact live-session generation. The router
/// itself is the published bus, so a retained reference becomes inert before
@ -52,6 +109,68 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
SendIfActive(() => bindings.SendTalk(command.Text));
});
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
commands.Register<AddShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
commands.Register<RemoveShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.RemoveShortcut(command.Index)));
commands.Register<AddFavoriteRuntimeCmd>(
command => SendIfActive(() => bindings.AddFavorite(
command.SpellId,
command.Position,
command.TabIndex)));
commands.Register<RemoveFavoriteRuntimeCmd>(
command => SendIfActive(() => bindings.RemoveFavorite(
command.SpellId,
command.TabIndex)));
commands.Register<SetSpellbookFilterRuntimeCmd>(
command => SendIfActive(() =>
bindings.SetSpellbookFilter(command.Filters)));
commands.Register<ForgetSpellRuntimeCmd>(
command => SendIfActive(() => bindings.ForgetSpell(command.SpellId)));
commands.Register<SetDesiredComponentRuntimeCmd>(
command => SendIfActive(() => bindings.SetDesiredComponent(
command.ComponentId,
command.Amount)));
commands.Register<ClearDesiredComponentsRuntimeCmd>(
_ => SendIfActive(bindings.ClearDesiredComponents));
commands.Register<RaiseAttributeRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseAttribute(command.StatId, command.Cost)));
commands.Register<RaiseVitalRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseVital(command.StatId, command.Cost)));
commands.Register<RaiseSkillRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseSkill(command.StatId, command.Cost)));
commands.Register<TrainSkillRuntimeCmd>(
command => SendIfActive(() =>
bindings.TrainSkill(command.StatId, command.Cost)));
commands.Register<SetCharacterOptionsRuntimeCmd>(
command => SendIfActive(() =>
bindings.SetCharacterOptions(command.Options)));
commands.Register<AddFriendRuntimeCmd>(
command => SendIfActive(() => bindings.AddFriend(command.Name)));
commands.Register<RemoveFriendRuntimeCmd>(
command => SendIfActive(() =>
bindings.RemoveFriend(command.CharacterId)));
commands.Register<ClearFriendsRuntimeCmd>(
_ => SendIfActive(bindings.ClearFriends));
commands.Register<RequestLegacyFriendsRuntimeCmd>(
_ => SendIfActive(bindings.RequestLegacyFriends));
commands.Register<ModifyCharacterSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyCharacterSquelch(
command.Add,
command.CharacterId,
command.Name,
command.MessageType)));
commands.Register<ModifyAccountSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyAccountSquelch(
command.Add,
command.Name)));
commands.Register<ModifyGlobalSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyGlobalSquelch(
command.Add,
command.MessageType)));
_commands = commands;
}

View file

@ -36,9 +36,7 @@ namespace AcDream.App.Net;
internal sealed record LiveSessionPlayerRuntime(
LocalPlayerIdentityState Identity,
LocalPlayerControllerSlot Controller,
LocalPlayerSkillState Skills,
LiveWorldOriginState WorldOrigin,
PlayerCharacterOptionsState CharacterOptions);
LiveWorldOriginState WorldOrigin);
internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects,
@ -215,8 +213,8 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Communication.ResetChatIdentity();
EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey();
_player.CharacterOptions.Reset();
_player.Skills.ResetSession();
_domain.Character.Options.ResetSession();
_domain.Character.MovementSkills.ResetSession();
_world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState();
_domain.Inventory.ResetPlayerSnapshots();
@ -282,27 +280,22 @@ internal sealed class LiveSessionRuntimeFactory
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_player.Skills.Update(
runSkill,
jumpSkill,
_player.Controller.Controller);
if (_player.Skills.IsComplete
&& _player.Controller.Controller is not null)
if (LocalPlayerSkillProjection.ApplyTo(
_domain.Character.MovementSkills,
_player.Controller.Controller))
{
RuntimeMovementSkillSnapshot snapshot =
_domain.Character.MovementSkills.Snapshot;
_log(
$"player: applied server skills " +
$"run={_player.Skills.RunSkill} " +
$"jump={_player.Skills.JumpSkill}");
$"run={snapshot.RunSkill} " +
$"jump={snapshot.JumpSkill}");
}
},
OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_player.CharacterOptions.Options =
(Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
}
@ -391,6 +384,26 @@ internal sealed class LiveSessionRuntimeFactory
senderGuid,
text,
cookie),
AddShortcut: session.SendAddShortcut,
RemoveShortcut: session.SendRemoveShortcut,
AddFavorite: session.SendAddSpellFavorite,
RemoveFavorite: session.SendRemoveSpellFavorite,
SetSpellbookFilter: session.SendSpellbookFilter,
ForgetSpell: session.SendRemoveSpell,
SetDesiredComponent: session.SendSetDesiredComponentLevel,
ClearDesiredComponents: session.SendClearDesiredComponents,
RaiseAttribute: session.SendRaiseAttribute,
RaiseVital: session.SendRaiseVital,
RaiseSkill: session.SendRaiseSkill,
TrainSkill: session.SendTrainSkill,
SetCharacterOptions: session.SendSetCharacterOptions,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Log: _log);
private static double ClientTimerNow() =>

View file

@ -440,12 +440,8 @@ public sealed class GameWindow :
get => _localPlayerIdentity.ServerGuid;
set => _localPlayerIdentity.ServerGuid = value;
}
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private readonly PlayerCharacterOptionsState _characterOptions = new();
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private readonly FramebufferResizeController _framebufferResize;
private AcDream.App.Input.PlayerModeController? _playerModeController;
@ -1269,7 +1265,6 @@ public sealed class GameWindow :
_localPlayerIdentity,
_playerControllerSlot,
_localPlayerMode,
_characterOptions,
viewPlane => new SelectionCameraSource(
hostInputCamera.CameraController,
_window!,
@ -1387,10 +1382,8 @@ public sealed class GameWindow :
_liveWorldOrigin,
_renderRange,
_localPlayerShadow,
_localPlayerSkills,
_viewportAspect,
_playerApproachCompletions,
_characterOptions,
_runtimeInventory,
_pointerPosition,
_movementInput,

View file

@ -37,6 +37,8 @@ internal sealed class CurrentGameRuntimeAdapter
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
@ -51,6 +53,8 @@ internal sealed class CurrentGameRuntimeAdapter
playerIdentity,
entities,
entityObjects,
inventory,
character,
communication,
playerController,
worldReveal,
@ -79,6 +83,9 @@ internal sealed class CurrentGameRuntimeAdapter
public IGameRuntimeClock Clock => _view.Clock;
public IRuntimeEntityView Entities => _view.Entities;
public IRuntimeInventoryView Inventory => _view.Inventory;
public IRuntimeInventoryStateView InventoryState => _view.InventoryState;
public IRuntimeCharacterView Character => _view.Character;
public IRuntimeSocialView Social => _view.Social;
public IRuntimeChatView Chat => _view.Chat;
public IRuntimeMovementView Movement => _view.Movement;
public IRuntimePortalView Portal => _view.Portal;
@ -92,6 +99,15 @@ internal sealed class CurrentGameRuntimeAdapter
IRuntimeChatCommands IGameRuntimeCommands.Chat => _commands;
public IRuntimePortalCommands PortalCommands => _commands;
IRuntimePortalCommands IGameRuntimeCommands.Portal => _commands;
public IRuntimeInventoryStateCommands InventoryCommands => _commands;
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState =>
_commands;
public IRuntimeSpellbookCommands SpellbookCommands => _commands;
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => _commands;
public IRuntimeCharacterCommands CharacterCommands => _commands;
IRuntimeCharacterCommands IGameRuntimeCommands.Character => _commands;
public IRuntimeSocialCommands SocialCommands => _commands;
IRuntimeSocialCommands IGameRuntimeCommands.Social => _commands;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
_view.CaptureCheckpoint();

View file

@ -3,6 +3,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.Core.Selection;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
@ -20,7 +21,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter
IRuntimeCombatCommands,
IRuntimeMovementCommands,
IRuntimeChatCommands,
IRuntimePortalCommands
IRuntimePortalCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands,
IRuntimeSocialCommands
{
private readonly LiveSessionController _session;
private readonly LiveSessionHost _sessionHost;
@ -276,6 +281,340 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (command.Index < 0
|| (command.ObjectId == 0u && command.SpellId == 0u))
{
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 0,
RuntimeCommandStatus.Rejected,
command.ObjectId);
}
_commands.Publish(new AddShortcutRuntimeCmd(new ShortcutEntry(
command.Index,
command.ObjectId,
command.SpellId)));
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 0,
RuntimeCommandStatus.Accepted,
command.ObjectId);
}
public RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (index < 0)
{
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 1,
RuntimeCommandStatus.Rejected);
}
_commands.Publish(new RemoveShortcutRuntimeCmd((uint)index));
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 1,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if ((uint)tabIndex >= 8u
|| position < 0
|| spellId == 0u)
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 0,
RuntimeCommandStatus.Rejected,
spellId);
}
_commands.Publish(new AddFavoriteRuntimeCmd(
spellId,
position,
tabIndex));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 0,
RuntimeCommandStatus.Accepted,
spellId);
}
public RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if ((uint)tabIndex >= 8u || spellId == 0u)
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 1,
RuntimeCommandStatus.Rejected,
spellId);
}
_commands.Publish(new RemoveFavoriteRuntimeCmd(spellId, tabIndex));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 1,
RuntimeCommandStatus.Accepted,
spellId);
}
public RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new SetSpellbookFilterRuntimeCmd(filters));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 2,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (spellId == 0u)
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 3,
RuntimeCommandStatus.Rejected,
spellId);
}
_commands.Publish(new ForgetSpellRuntimeCmd(spellId));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 3,
RuntimeCommandStatus.Accepted,
spellId);
}
public RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (componentId == 0u)
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 4,
RuntimeCommandStatus.Rejected,
componentId);
}
_commands.Publish(new SetDesiredComponentRuntimeCmd(
componentId,
amount));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 4,
RuntimeCommandStatus.Accepted,
componentId);
}
public RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new ClearDesiredComponentsRuntimeCmd());
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 5,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (command.StatId == 0u
|| command.Cost == 0u)
{
return EmitResult(
RuntimeCommandDomain.Character,
(int)command.Kind,
RuntimeCommandStatus.Rejected,
command.StatId);
}
switch (command.Kind)
{
case RuntimeAdvancementKind.Attribute:
_commands.Publish(new RaiseAttributeRuntimeCmd(
command.StatId,
command.Cost));
break;
case RuntimeAdvancementKind.Vital:
_commands.Publish(new RaiseVitalRuntimeCmd(
command.StatId,
command.Cost));
break;
case RuntimeAdvancementKind.Skill:
_commands.Publish(new RaiseSkillRuntimeCmd(
command.StatId,
command.Cost));
break;
case RuntimeAdvancementKind.TrainSkill
when command.Cost <= uint.MaxValue:
_commands.Publish(new TrainSkillRuntimeCmd(
command.StatId,
(uint)command.Cost));
break;
default:
return EmitResult(
RuntimeCommandDomain.Character,
(int)command.Kind,
RuntimeCommandStatus.Rejected,
command.StatId);
}
return EmitResult(
RuntimeCommandDomain.Character,
(int)command.Kind,
RuntimeCommandStatus.Accepted,
command.StatId);
}
public RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new SetCharacterOptionsRuntimeCmd(options));
return EmitResult(
RuntimeCommandDomain.Character,
operation: 4,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeFriendCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
switch (command.Kind)
{
case RuntimeFriendCommandKind.Add
when !string.IsNullOrWhiteSpace(command.Name):
_commands.Publish(new AddFriendRuntimeCmd(command.Name));
break;
case RuntimeFriendCommandKind.Remove
when command.CharacterId != 0u:
_commands.Publish(new RemoveFriendRuntimeCmd(
command.CharacterId));
break;
case RuntimeFriendCommandKind.Clear:
_commands.Publish(new ClearFriendsRuntimeCmd());
break;
case RuntimeFriendCommandKind.RequestLegacyList:
_commands.Publish(new RequestLegacyFriendsRuntimeCmd());
break;
default:
status = RuntimeCommandStatus.Rejected;
break;
}
return EmitResult(
RuntimeCommandDomain.Social,
(int)command.Kind,
status,
command.CharacterId,
command.Name);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeSquelchCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
switch (command.Scope)
{
case RuntimeSquelchScope.Character
when command.CharacterId != 0u
&& !string.IsNullOrWhiteSpace(command.Name):
_commands.Publish(new ModifyCharacterSquelchRuntimeCmd(
command.Add,
command.CharacterId,
command.Name,
command.MessageType));
break;
case RuntimeSquelchScope.Account
when !string.IsNullOrWhiteSpace(command.Name):
_commands.Publish(new ModifyAccountSquelchRuntimeCmd(
command.Add,
command.Name));
break;
case RuntimeSquelchScope.Global:
_commands.Publish(new ModifyGlobalSquelchRuntimeCmd(
command.Add,
command.MessageType));
break;
default:
status = RuntimeCommandStatus.Rejected;
break;
}
return EmitResult(
RuntimeCommandDomain.Social,
operation: 4 + (int)command.Scope,
status,
command.CharacterId,
command.Name);
}
private RuntimeCommandStatus Validate(
RuntimeGenerationToken expectedGeneration,
bool requireWorld)
@ -294,6 +633,17 @@ internal sealed class CurrentGameRuntimeCommandAdapter
uint objectId = 0u) =>
new(status, _view.Generation, objectId);
private RuntimeCommandResult EmitResult(
RuntimeCommandDomain domain,
int operation,
RuntimeCommandStatus status,
uint objectId = 0u,
string? text = null)
{
_events.EmitCommand(domain, operation, status, objectId, text);
return Result(status, objectId);
}
private RuntimeSessionStartResult RejectedStart(RuntimeCommandStatus status) =>
new(
status == RuntimeCommandStatus.StaleGeneration

View file

@ -24,6 +24,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IGameRuntimeClock _clock;
private readonly IRuntimeEntityView _entityView;
private readonly IRuntimeInventoryView _inventoryView;
private readonly IRuntimeInventoryStateView _inventoryStateView;
private readonly IRuntimeCharacterView _characterView;
private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView;
private readonly MovementView _movementView;
private readonly PortalView _portalView;
@ -34,6 +37,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
@ -50,7 +55,12 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entityView = entityObjects.EntityView;
_inventoryView = entityObjects.InventoryView;
_inventoryStateView = (
inventory ?? throw new ArgumentNullException(nameof(inventory))).View;
_characterView = (
character ?? throw new ArgumentNullException(nameof(character))).View;
_chatView = communication.View;
_socialView = communication.SocialView;
_movementView = new MovementView(
playerController
?? throw new ArgumentNullException(nameof(playerController)),
@ -90,6 +100,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
public IGameRuntimeClock Clock => _clock;
public IRuntimeEntityView Entities => _entityView;
public IRuntimeInventoryView Inventory => _inventoryView;
public IRuntimeInventoryStateView InventoryState => _inventoryStateView;
public IRuntimeCharacterView Character => _characterView;
public IRuntimeSocialView Social => _socialView;
public IRuntimeChatView Chat => _chatView;
public IRuntimeMovementView Movement => _movementView;
public IRuntimePortalView Portal => _portalView;
@ -103,6 +116,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entities.MaterializedCount,
_objects.ObjectCount,
_objects.ContainerCount,
_inventoryStateView.Snapshot,
_characterView.Snapshot,
_socialView.Snapshot,
_chatView.Revision,
_chatView.Count,
_movementView.Snapshot,

View file

@ -1747,6 +1747,16 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
}
/// <summary>
/// Send retail SetCharacterOptions (0x01A1) for the first character
/// option bitmap.
/// </summary>
public void SendSetCharacterOptions(uint options)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options));
}
public void SendAddFriend(string name)
{
uint seq = NextGameActionSequence();

View file

@ -12,6 +12,7 @@ namespace AcDream.Core.Items;
public sealed class ItemManaState
{
private readonly ConcurrentDictionary<uint, float> _manaByGuid = new();
private long _revision;
/// <summary>Fires for every valid or invalid query response.</summary>
public event Action<uint /*guid*/, float /*fraction*/, bool /*valid*/>? ItemManaChanged;
@ -20,6 +21,11 @@ public sealed class ItemManaState
_manaByGuid.TryGetValue(guid, out float percent) ? percent : 0f;
public bool HasMana(uint guid) => _manaByGuid.ContainsKey(guid);
public int Count => _manaByGuid.Count;
public long Revision => Interlocked.Read(ref _revision);
public bool TryGetManaPercent(uint guid, out float fraction) =>
_manaByGuid.TryGetValue(guid, out fraction);
public void OnQueryItemManaResponse(uint itemGuid, float manaPercent, bool valid)
{
@ -28,8 +34,13 @@ public sealed class ItemManaState
else
_manaByGuid.TryRemove(itemGuid, out _);
Interlocked.Increment(ref _revision);
ItemManaChanged?.Invoke(itemGuid, manaPercent, valid);
}
public void Clear() => _manaByGuid.Clear();
public void Clear()
{
_manaByGuid.Clear();
Interlocked.Increment(ref _revision);
}
}

View file

@ -5,12 +5,29 @@ public sealed class FriendsState
{
private readonly object _gate = new();
private readonly List<FriendEntry> _entries = new();
private long _revision;
public int Count
{
get { lock (_gate) return _entries.Count; }
}
public long Revision => Interlocked.Read(ref _revision);
public IReadOnlyList<FriendEntry> Snapshot()
{
lock (_gate) return _entries.ToArray();
}
public bool TryGet(uint characterId, out FriendEntry? friend)
{
lock (_gate)
{
friend = _entries.Find(candidate => candidate.Id == characterId);
return friend is not null;
}
}
public void Apply(FriendsUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
@ -43,12 +60,17 @@ public sealed class FriendsState
}
break;
}
Interlocked.Increment(ref _revision);
}
}
public void Clear()
{
lock (_gate) _entries.Clear();
lock (_gate)
{
_entries.Clear();
Interlocked.Increment(ref _revision);
}
}
}

View file

@ -5,6 +5,9 @@ public sealed class SquelchState
{
private readonly object _gate = new();
private SquelchDatabase _database = SquelchDatabase.Empty;
private long _revision;
public long Revision => Interlocked.Read(ref _revision);
public SquelchDatabase Snapshot()
{
@ -14,7 +17,11 @@ public sealed class SquelchState
public void Replace(SquelchDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
lock (_gate) _database = database;
lock (_gate)
{
_database = database;
Interlocked.Increment(ref _revision);
}
}
/// <summary>
@ -25,7 +32,11 @@ public sealed class SquelchState
/// </summary>
public void Clear()
{
lock (_gate) _database = SquelchDatabase.Empty;
lock (_gate)
{
_database = SquelchDatabase.Empty;
Interlocked.Increment(ref _revision);
}
}
}

View file

@ -135,6 +135,114 @@ public interface IRuntimePortalCommands
RuntimePortalCommand command);
}
public readonly record struct RuntimeShortcutCommand(
int Index,
uint ObjectId,
uint SpellId);
public interface IRuntimeInventoryStateCommands
{
RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command);
RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index);
}
public interface IRuntimeSpellbookCommands
{
RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId);
RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId);
RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters);
RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId);
RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount);
RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration);
}
public enum RuntimeAdvancementKind
{
Attribute,
Vital,
Skill,
TrainSkill,
}
public readonly record struct RuntimeAdvancementCommand(
RuntimeAdvancementKind Kind,
uint StatId,
ulong Cost);
public interface IRuntimeCharacterCommands
{
RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command);
RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options);
}
public enum RuntimeFriendCommandKind
{
Add,
Remove,
Clear,
RequestLegacyList,
}
public readonly record struct RuntimeFriendCommand(
RuntimeFriendCommandKind Kind,
uint CharacterId = 0u,
string? Name = null);
public enum RuntimeSquelchScope
{
Character,
Account,
Global,
}
public readonly record struct RuntimeSquelchCommand(
RuntimeSquelchScope Scope,
bool Add,
uint CharacterId = 0u,
string? Name = null,
uint MessageType = 0u);
public interface IRuntimeSocialCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeFriendCommand command);
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeSquelchCommand command);
}
public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
@ -148,4 +256,12 @@ public interface IGameRuntimeCommands
IRuntimeChatCommands Chat { get; }
IRuntimePortalCommands Portal { get; }
IRuntimeInventoryStateCommands InventoryState { get; }
IRuntimeSpellbookCommands Spellbook { get; }
IRuntimeCharacterCommands Character { get; }
IRuntimeSocialCommands Social { get; }
}

View file

@ -13,6 +13,10 @@ public enum RuntimeCommandDomain
Movement,
Chat,
Portal,
InventoryState,
Spellbook,
Character,
Social,
}
public readonly record struct RuntimeLifecycleDelta(
@ -145,6 +149,19 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
System.Globalization.CultureInfo.InvariantCulture,
$"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" +
$"containers={checkpoint.InventoryContainerCount};chat={checkpoint.ChatCount};" +
$"inventory-state={checkpoint.InventoryState.ShortcutCount}:" +
$"{checkpoint.InventoryState.ShortcutRevision}:" +
$"{checkpoint.InventoryState.ItemManaCount}:" +
$"{checkpoint.InventoryState.ItemManaRevision};" +
$"character={checkpoint.Character.CharacterRevision}:" +
$"{checkpoint.Character.SpellbookRevision}:" +
$"{checkpoint.Character.LearnedSpellCount}:" +
$"{checkpoint.Character.SkillCount}:" +
$"{checkpoint.Character.Options.Revision}:" +
$"{checkpoint.Character.MovementSkills.Revision};" +
$"social={checkpoint.Social.FriendsRevision}:" +
$"{checkpoint.Social.FriendCount}:" +
$"{checkpoint.Social.SquelchRevision};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -0,0 +1,110 @@
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime;
public readonly record struct RuntimePendingInventoryRequestSnapshot(
ulong Token,
int Kind,
uint ItemId,
bool Dispatched);
public readonly record struct RuntimeInventoryStateSnapshot(
uint RequestedExternalContainerId,
uint CurrentExternalContainerId,
int BusyCount,
bool CanBeginRequest,
RuntimePendingInventoryRequestSnapshot? PendingRequest,
int ShortcutCount,
long ShortcutRevision,
int ItemManaCount,
long ItemManaRevision);
public readonly record struct RuntimeShortcutSnapshot(
int Index,
uint ObjectId,
uint SpellId);
public interface IRuntimeInventoryStateView
{
RuntimeInventoryStateSnapshot Snapshot { get; }
bool TryGetShortcut(int index, out RuntimeShortcutSnapshot shortcut);
bool TryGetItemMana(uint objectId, out float fraction);
}
public readonly record struct RuntimeCharacterSnapshot(
long CharacterRevision,
long SpellbookRevision,
RuntimeCharacterOptionsSnapshot Options,
RuntimeMovementSkillSnapshot MovementSkills,
int LearnedSpellCount,
int ActiveEnchantmentCount,
int DesiredComponentCount,
int SkillCount,
uint SpellbookFilters);
public readonly record struct RuntimeVitalSnapshot(
int Kind,
uint Ranks,
uint Start,
uint Experience,
uint Current,
uint Maximum);
public readonly record struct RuntimeAttributeSnapshot(
int Kind,
uint Ranks,
uint Start,
uint Experience,
uint Current);
public readonly record struct RuntimeSkillSnapshot(
uint SkillId,
uint Ranks,
uint Status,
uint Experience,
uint Initial,
uint Resistance,
double LastUsed,
uint FormulaBonus,
uint CurrentLevel);
public interface IRuntimeCharacterView
{
RuntimeCharacterSnapshot Snapshot { get; }
bool TryGetVital(int kind, out RuntimeVitalSnapshot vital);
bool TryGetAttribute(int kind, out RuntimeAttributeSnapshot attribute);
bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill);
bool KnowsSpell(uint spellId);
bool TryGetFavorite(int tabIndex, int position, out uint spellId);
bool TryGetDesiredComponent(uint componentId, out uint amount);
}
public readonly record struct RuntimeSocialSnapshot(
long FriendsRevision,
int FriendCount,
long SquelchRevision,
int SquelchedAccountCount,
int SquelchedCharacterCount,
int GlobalSquelchTypeCount,
int NegotiatedChatRoomCount);
public readonly record struct RuntimeFriendSnapshot(
uint Id,
string Name,
bool Online,
bool AppearOffline);
public interface IRuntimeSocialView
{
RuntimeSocialSnapshot Snapshot { get; }
bool TryGetFriend(uint characterId, out RuntimeFriendSnapshot friend);
}

View file

@ -110,6 +110,9 @@ public readonly record struct RuntimeStateCheckpoint(
int MaterializedEntityCount,
int InventoryObjectCount,
int InventoryContainerCount,
RuntimeInventoryStateSnapshot InventoryState,
RuntimeCharacterSnapshot Character,
RuntimeSocialSnapshot Social,
long ChatRevision,
int ChatCount,
RuntimeMovementSnapshot Movement,
@ -127,6 +130,12 @@ public interface IGameRuntimeView
IRuntimeInventoryView Inventory { get; }
IRuntimeInventoryStateView InventoryState { get; }
IRuntimeCharacterView Character { get; }
IRuntimeSocialView Social { get; }
IRuntimeChatView Chat { get; }
IRuntimeMovementView Movement { get; }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
@ -11,15 +12,27 @@ namespace AcDream.Runtime.Gameplay;
public sealed class RuntimeCharacterState : IDisposable
{
private bool _disposed;
private long _characterRevision;
private long _spellbookRevision;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
Spellbook = new Spellbook(spellTable);
LocalPlayer = new LocalPlayerState(Spellbook);
Options = new RuntimeCharacterOptionsState();
MovementSkills = new RuntimeMovementSkillState();
View = new CharacterView(this);
Spellbook.StateChanged += OnSpellbookChanged;
LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged;
}
public Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; }
public RuntimeCharacterOptionsState Options { get; }
public RuntimeMovementSkillState MovementSkills { get; }
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed;
/// <summary>
@ -55,6 +68,8 @@ public sealed class RuntimeCharacterState : IDisposable
List<Exception>? failures = null;
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures);
Try(MovementSkills.ResetSession, ref failures);
if (failures is not null)
{
throw new AggregateException(
@ -68,9 +83,25 @@ public sealed class RuntimeCharacterState : IDisposable
if (_disposed)
return;
ResetSession();
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_disposed = true;
}
private void OnSpellbookChanged() =>
Interlocked.Increment(ref _spellbookRevision);
private void OnVitalChanged(LocalPlayerState.VitalKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnCharacterChanged() =>
Interlocked.Increment(ref _characterRevision);
private static void Try(Action action, ref List<Exception>? failures)
{
try
@ -82,4 +113,212 @@ public sealed class RuntimeCharacterState : IDisposable
(failures ??= []).Add(error);
}
}
private sealed class CharacterView(RuntimeCharacterState owner)
: IRuntimeCharacterView
{
public RuntimeCharacterSnapshot Snapshot => new(
Interlocked.Read(ref owner._characterRevision),
Interlocked.Read(ref owner._spellbookRevision),
owner.Options.Snapshot,
owner.MovementSkills.Snapshot,
owner.Spellbook.LearnedSpells.Count,
owner.Spellbook.ActiveEnchantments.Count(),
owner.Spellbook.DesiredComponents.Count,
owner.LocalPlayer.Skills.Count,
owner.Spellbook.SpellbookFilters);
public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital)
{
if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind)
|| owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind)
is not LocalPlayerState.VitalSnapshot current)
{
vital = default;
return false;
}
vital = new RuntimeVitalSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current,
owner.LocalPlayer.GetMaxApprox(
(LocalPlayerState.VitalKind)kind) ?? 0u);
return true;
}
public bool TryGetAttribute(
int kind,
out RuntimeAttributeSnapshot attribute)
{
if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind)
|| owner.LocalPlayer.GetAttribute(
(LocalPlayerState.AttributeKind)kind)
is not LocalPlayerState.AttributeSnapshot current)
{
attribute = default;
return false;
}
attribute = new RuntimeAttributeSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current);
return true;
}
public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill)
{
if (owner.LocalPlayer.GetSkill(skillId)
is not LocalPlayerState.SkillSnapshot current)
{
skill = default;
return false;
}
skill = new RuntimeSkillSnapshot(
current.SkillId,
current.Ranks,
current.Status,
current.Xp,
current.Init,
current.Resistance,
current.LastUsed,
current.FormulaBonus,
current.CurrentLevel);
return true;
}
public bool KnowsSpell(uint spellId) =>
owner.Spellbook.Knows(spellId);
public bool TryGetFavorite(
int tabIndex,
int position,
out uint spellId)
{
IReadOnlyList<uint> favorites =
owner.Spellbook.GetFavorites(tabIndex);
if ((uint)position >= (uint)favorites.Count)
{
spellId = 0u;
return false;
}
spellId = favorites[position];
return true;
}
public bool TryGetDesiredComponent(
uint componentId,
out uint amount) =>
owner.Spellbook.DesiredComponents.TryGetValue(
componentId,
out amount);
}
}
public readonly record struct RuntimeCharacterOptionsSnapshot(
uint Options1,
uint Options2,
long Revision)
{
public bool DragItemOnPlayerOpensSecureTrade =>
(Options1
& (uint)PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0u;
}
/// <summary>
/// Canonical session-owned copy of retail's two character-option bitfields.
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
/// Runtime reset restores the equivalent fresh-player-module state because
/// one Runtime owner survives across graphical and no-window sessions.
/// </summary>
public sealed class RuntimeCharacterOptionsState
{
public const uint DefaultOptions1 =
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
public const uint DefaultOptions2 = 0x00948700u;
private uint _options1 = DefaultOptions1;
private uint _options2 = DefaultOptions2;
private long _revision;
public uint Options1 => Volatile.Read(ref _options1);
public uint Options2 => Volatile.Read(ref _options2);
public long Revision => Interlocked.Read(ref _revision);
public RuntimeCharacterOptionsSnapshot Snapshot =>
new(_options1, _options2, Revision);
public bool DragItemOnPlayerOpensSecureTrade =>
Snapshot.DragItemOnPlayerOpensSecureTrade;
public void Replace(uint options1, uint options2)
{
Volatile.Write(ref _options1, options1);
Volatile.Write(ref _options2, options2);
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _options1, DefaultOptions1);
Volatile.Write(ref _options2, DefaultOptions2);
Interlocked.Increment(ref _revision);
}
}
public readonly record struct RuntimeMovementSkillSnapshot(
int RunSkill,
int JumpSkill,
long Revision)
{
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
}
/// <summary>
/// Server-authoritative run/jump values retained independently of any
/// graphical movement controller. App applies this borrowed state whenever
/// its presentation/physics controller exists or is rebuilt.
/// </summary>
public sealed class RuntimeMovementSkillState
{
private int _runSkill = -1;
private int _jumpSkill = -1;
private long _revision;
public int RunSkill => Volatile.Read(ref _runSkill);
public int JumpSkill => Volatile.Read(ref _jumpSkill);
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
public long Revision => Interlocked.Read(ref _revision);
public RuntimeMovementSkillSnapshot Snapshot =>
new(_runSkill, _jumpSkill, Revision);
public void Update(int runSkill, int jumpSkill)
{
bool changed = false;
if (runSkill >= 0 && RunSkill != runSkill)
{
Volatile.Write(ref _runSkill, runSkill);
changed = true;
}
if (jumpSkill >= 0 && JumpSkill != jumpSkill)
{
Volatile.Write(ref _jumpSkill, jumpSkill);
changed = true;
}
if (changed)
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _runSkill, -1);
Volatile.Write(ref _jumpSkill, -1);
Interlocked.Increment(ref _revision);
}
}

View file

@ -36,6 +36,10 @@ public sealed class RuntimeCommunicationState : IDisposable
Friends = new FriendsState();
Squelch = new SquelchState();
View = new CommunicationView(Chat);
SocialView = new CommunicationSocialView(
TurbineChat,
Friends,
Squelch);
}
public ChatLog Chat { get; }
@ -44,6 +48,7 @@ public sealed class RuntimeCommunicationState : IDisposable
public FriendsState Friends { get; }
public SquelchState Squelch { get; }
public IRuntimeChatView View { get; }
public IRuntimeSocialView SocialView { get; }
public IRuntimeCommunicationEventSource Events => _events;
public bool IsDisposed => _disposed;
@ -79,6 +84,61 @@ public sealed class RuntimeCommunicationState : IDisposable
public long Revision => chat.Revision;
public int Count => chat.Count;
}
private sealed class CommunicationSocialView(
TurbineChatState turbineChat,
FriendsState friends,
SquelchState squelch)
: IRuntimeSocialView
{
public RuntimeSocialSnapshot Snapshot
{
get
{
SquelchDatabase database = squelch.Snapshot();
return new RuntimeSocialSnapshot(
friends.Revision,
friends.Count,
squelch.Revision,
database.Accounts.Count,
database.Characters.Count,
database.Global.MessageTypes.Count,
CountRooms(turbineChat));
}
}
public bool TryGetFriend(
uint characterId,
out RuntimeFriendSnapshot friend)
{
if (!friends.TryGet(characterId, out FriendEntry? current)
|| current is null)
{
friend = default;
return false;
}
friend = new RuntimeFriendSnapshot(
current.Id,
current.Name,
current.Online,
current.AppearOffline);
return true;
}
private static int CountRooms(TurbineChatState state)
{
int count = 0;
if (state.AllegianceRoom != 0u) count++;
if (state.GeneralRoom != 0u) count++;
if (state.TradeRoom != 0u) count++;
if (state.LfgRoom != 0u) count++;
if (state.RoleplayRoom != 0u) count++;
if (state.SocietyRoom != 0u) count++;
if (state.OlthoiRoom != 0u) count++;
return count;
}
}
}
internal sealed class RuntimeCommunicationEventStream

View file

@ -21,6 +21,7 @@ public sealed class RuntimeInventoryState : IDisposable
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this);
}
public ClientObjectTable Objects => _entityObjects.Objects;
@ -28,6 +29,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
public void ResetExternalContainer() => ExternalContainers.Reset();
@ -66,6 +68,61 @@ public sealed class RuntimeInventoryState : IDisposable
(failures ??= []).Add(error);
}
}
private sealed class InventoryStateView(RuntimeInventoryState owner)
: IRuntimeInventoryStateView
{
public RuntimeInventoryStateSnapshot Snapshot
{
get
{
RuntimePendingInventoryRequestSnapshot? pending = null;
if (owner.Transactions.TryGetPending(
out PendingInventoryRequest request))
{
pending = new RuntimePendingInventoryRequestSnapshot(
request.Token,
(int)request.Kind,
request.ItemId,
request.Dispatched);
}
return new RuntimeInventoryStateSnapshot(
owner.ExternalContainers.RequestedContainerId,
owner.ExternalContainers.CurrentContainerId,
owner.Transactions.BusyCount,
owner.Transactions.CanBeginRequest,
pending,
owner.Shortcuts.Items.Count,
owner.Shortcuts.Revision,
owner.ItemMana.Count,
owner.ItemMana.Revision);
}
}
public bool TryGetShortcut(
int index,
out RuntimeShortcutSnapshot shortcut)
{
IReadOnlyList<ShortcutEntry> shortcuts = owner.Shortcuts.Items;
for (int i = 0; i < shortcuts.Count; i++)
{
if (shortcuts[i].Index != index)
continue;
ShortcutEntry current = shortcuts[i];
shortcut = new RuntimeShortcutSnapshot(
current.Index,
current.ObjectId,
current.SpellId);
return true;
}
shortcut = default;
return false;
}
public bool TryGetItemMana(uint objectId, out float fraction) =>
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
}
}
public sealed class ShortcutState

View file

@ -44,7 +44,6 @@ public sealed record LiveCharacterSessionBindings(
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Action<uint, uint>? OnCharacterOptions,
Func<double>? ClientTime);
public sealed record LiveSocialSessionBindings(
@ -152,7 +151,13 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
social.Chat,
character.Character.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: character.OnSkillsUpdated,
onSkillsUpdated: (runSkill, jumpSkill) =>
{
character.Character.MovementSkills.Update(
runSkill,
jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
},
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
@ -164,7 +169,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: null,
onCharacterOptions: character.OnCharacterOptions,
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
accepting: IsAccepting));