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.Player;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
@ -56,7 +57,6 @@ internal sealed record InteractionRetainedUiDependencies(
ILocalPlayerIdentitySource PlayerIdentity, ILocalPlayerIdentitySource PlayerIdentity,
ILocalPlayerControllerSource PlayerController, ILocalPlayerControllerSource PlayerController,
ILocalPlayerModeSource PlayerMode, ILocalPlayerModeSource PlayerMode,
PlayerCharacterOptionsState CharacterOptions,
Func<DeferredSelectionViewPlaneSource, SelectionCameraSource> Func<DeferredSelectionViewPlaneSource, SelectionCameraSource>
SelectionCameraFactory, SelectionCameraFactory,
DeferredRenderFrameDiagnosticsSource FrameDiagnostics, DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
@ -118,6 +118,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
private bool _deactivationStarted; private bool _deactivationStarted;
public DeferredLiveSessionUiAuthority Session { get; } = new(); public DeferredLiveSessionUiAuthority Session { get; } = new();
public DeferredGameRuntimeStateCommands GameRuntime { get; } = new();
public DeferredSelectionUiAuthority Selection { get; } = new(); public DeferredSelectionUiAuthority Selection { get; } = new();
public DeferredSelectionViewPlaneSource SelectionViewPlane { get; } = new(); public DeferredSelectionViewPlaneSource SelectionViewPlane { get; } = new();
public DeferredRadarSnapshotSource Radar { get; } = new(); public DeferredRadarSnapshotSource Radar { get; } = new();
@ -176,6 +177,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
Radar.Deactivate(); Radar.Deactivate();
SelectionViewPlane.Deactivate(); SelectionViewPlane.Deactivate();
Selection.Deactivate(); Selection.Deactivate();
GameRuntime.Deactivate();
Session.Deactivate(); Session.Deactivate();
InventoryContainer.Deactivate(); InventoryContainer.Deactivate();
for (int i = _lateOwnerBindings.Count - 1; i >= 0; i--) for (int i = _lateOwnerBindings.Count - 1; i >= 0; i--)
@ -294,7 +296,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
sendGive: (target, item, amount) => sendGive: (target, item, amount) =>
session.CurrentSession?.SendGiveObject(target, item, amount), session.CurrentSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () => dragOnPlayerOpensSecureTrade: () =>
d.CharacterOptions.DragItemOnPlayerOpensSecureTrade, d.Character.Options.DragItemOnPlayerOpensSecureTrade,
toast: d.Toast, toast: d.Toast,
readyForInventoryRequest: () => session.IsInWorld, readyForInventoryRequest: () => session.IsInWorld,
playerOnGround: () => playerOnGround: () =>
@ -375,15 +377,27 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
playerGuid: () => d.PlayerIdentity.ServerGuid, playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey, activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter, fallbackSheet: Studio.SampleData.SampleCharacter,
canSendRaise: () => late.Session.IsInWorld, canSendRaise: () => late.GameRuntime.IsInWorld,
sendRaiseAttribute: (statId, cost) => sendRaiseAttribute: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseAttribute(statId, cost), late.GameRuntime.Advance(
RuntimeAdvancementKind.Attribute,
statId,
cost),
sendRaiseVital: (statId, cost) => sendRaiseVital: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseVital(statId, cost), late.GameRuntime.Advance(
RuntimeAdvancementKind.Vital,
statId,
cost),
sendRaiseSkill: (statId, cost) => sendRaiseSkill: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseSkill(statId, cost), late.GameRuntime.Advance(
RuntimeAdvancementKind.Skill,
statId,
cost),
sendTrainSkill: (statId, credits) => sendTrainSkill: (statId, credits) =>
late.Session.CurrentSession?.SendTrainSkill(statId, credits)); late.GameRuntime.Advance(
RuntimeAdvancementKind.TrainSkill,
statId,
credits));
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
MagicRuntime magic = MagicRuntime.Create( MagicRuntime magic = MagicRuntime.Create(
@ -543,16 +557,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory), guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log), guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) => (tab, position, spellId) =>
late.Session.CurrentSession?.SendAddSpellFavorite( late.GameRuntime.AddFavorite(tab, position, spellId),
spellId,
position,
tab),
(tab, spellId) => (tab, spellId) =>
late.Session.CurrentSession?.SendRemoveSpellFavorite(spellId, tab), late.GameRuntime.RemoveFavorite(tab, spellId),
filters => late.Session.CurrentSession?.SendSpellbookFilter(filters), filters => late.GameRuntime.SetSpellbookFilter(filters),
spellId => late.Session.CurrentSession?.SendRemoveSpell(spellId), spellId => late.GameRuntime.ForgetSpell(spellId),
(componentId, amount) => (componentId, amount) =>
late.Session.CurrentSession?.SendSetDesiredComponentLevel( late.GameRuntime.SetDesiredComponent(
componentId, componentId,
amount), amount),
d.ClientTime), d.ClientTime),
@ -589,8 +600,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Inventory.ItemMana, d.Inventory.ItemMana,
d.CombatModeCommands.Toggle, d.CombatModeCommands.Toggle,
itemInteraction, itemInteraction,
entry => late.Session.CurrentSession?.SendAddShortcut(entry), entry => late.GameRuntime.AddShortcut(entry),
index => late.Session.CurrentSession?.SendRemoveShortcut(index), index => late.GameRuntime.RemoveShortcut(index),
d.Selection, d.Selection,
handler => d.Combat.HealthChanged += handler, handler => d.Combat.HealthChanged += handler,
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.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using Silk.NET.Windowing; using Silk.NET.Windowing;
namespace AcDream.App.Composition; 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> /// <summary>
/// Early UI view over the later live-session owner. A binding lease clears only /// 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. /// 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> /// <summary>
/// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in /// 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. /// Phase 7. Calls remain inert and diagnostic until that exact owner binds.

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -15,8 +16,64 @@ internal sealed record LiveSessionCommandBindings(
Action<string, string> SendTell, Action<string, string> SendTell,
Action<uint, string> SendChannel, Action<uint, string> SendChannel,
Action<uint, uint, uint, uint, string, uint> SendTurbineChat, 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); 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> /// <summary>
/// Owns the command surface for one exact live-session generation. The router /// Owns the command surface for one exact live-session generation. The router
/// itself is the published bus, so a retained reference becomes inert before /// 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)); SendIfActive(() => bindings.SendTalk(command.Text));
}); });
commands.Register<SendChatCmd>(command => RouteChat(bindings, command)); 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; _commands = commands;
} }

View file

@ -36,9 +36,7 @@ namespace AcDream.App.Net;
internal sealed record LiveSessionPlayerRuntime( internal sealed record LiveSessionPlayerRuntime(
LocalPlayerIdentityState Identity, LocalPlayerIdentityState Identity,
LocalPlayerControllerSlot Controller, LocalPlayerControllerSlot Controller,
LocalPlayerSkillState Skills, LiveWorldOriginState WorldOrigin);
LiveWorldOriginState WorldOrigin,
PlayerCharacterOptionsState CharacterOptions);
internal sealed record LiveSessionDomainRuntime( internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
@ -215,8 +213,8 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Communication.ResetChatIdentity(); _domain.Communication.ResetChatIdentity();
EntityVanishProbe.PlayerGuid = 0u; EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey(); _interaction.Settings.ResetActiveCharacterKey();
_player.CharacterOptions.Reset(); _domain.Character.Options.ResetSession();
_player.Skills.ResetSession(); _domain.Character.MovementSkills.ResetSession();
_world.NetworkUpdates.ResetSessionState(); _world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState(); _world.Hydration.ResetSessionState();
_domain.Inventory.ResetPlayerSnapshots(); _domain.Inventory.ResetPlayerSnapshots();
@ -282,27 +280,22 @@ internal sealed class LiveSessionRuntimeFactory
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => OnSkillsUpdated: (runSkill, jumpSkill) =>
{ {
_player.Skills.Update( if (LocalPlayerSkillProjection.ApplyTo(
runSkill, _domain.Character.MovementSkills,
jumpSkill, _player.Controller.Controller))
_player.Controller.Controller);
if (_player.Skills.IsComplete
&& _player.Controller.Controller is not null)
{ {
RuntimeMovementSkillSnapshot snapshot =
_domain.Character.MovementSkills.Snapshot;
_log( _log(
$"player: applied server skills " + $"player: applied server skills " +
$"run={_player.Skills.RunSkill} " + $"run={snapshot.RunSkill} " +
$"jump={_player.Skills.JumpSkill}"); $"jump={snapshot.JumpSkill}");
} }
}, },
OnConfirmationRequest: request => OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request), _ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done => OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done), _ui.RetailUi?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_player.CharacterOptions.Options =
(Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow); ClientTime: ClientTimerNow);
} }
@ -391,6 +384,26 @@ internal sealed class LiveSessionRuntimeFactory
senderGuid, senderGuid,
text, text,
cookie), 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); Log: _log);
private static double ClientTimerNow() => private static double ClientTimerNow() =>

View file

@ -440,12 +440,8 @@ public sealed class GameWindow :
get => _localPlayerIdentity.ServerGuid; get => _localPlayerIdentity.ServerGuid;
set => _localPlayerIdentity.ServerGuid = value; 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.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private readonly FramebufferResizeController _framebufferResize; private readonly FramebufferResizeController _framebufferResize;
private AcDream.App.Input.PlayerModeController? _playerModeController; private AcDream.App.Input.PlayerModeController? _playerModeController;
@ -1269,7 +1265,6 @@ public sealed class GameWindow :
_localPlayerIdentity, _localPlayerIdentity,
_playerControllerSlot, _playerControllerSlot,
_localPlayerMode, _localPlayerMode,
_characterOptions,
viewPlane => new SelectionCameraSource( viewPlane => new SelectionCameraSource(
hostInputCamera.CameraController, hostInputCamera.CameraController,
_window!, _window!,
@ -1387,10 +1382,8 @@ public sealed class GameWindow :
_liveWorldOrigin, _liveWorldOrigin,
_renderRange, _renderRange,
_localPlayerShadow, _localPlayerShadow,
_localPlayerSkills,
_viewportAspect, _viewportAspect,
_playerApproachCompletions, _playerApproachCompletions,
_characterOptions,
_runtimeInventory, _runtimeInventory,
_pointerPosition, _pointerPosition,
_movementInput, _movementInput,

View file

@ -37,6 +37,8 @@ internal sealed class CurrentGameRuntimeAdapter
LocalPlayerIdentityState playerIdentity, LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities, LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects, RuntimeEntityObjectLifetime entityObjects,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController, ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
@ -51,6 +53,8 @@ internal sealed class CurrentGameRuntimeAdapter
playerIdentity, playerIdentity,
entities, entities,
entityObjects, entityObjects,
inventory,
character,
communication, communication,
playerController, playerController,
worldReveal, worldReveal,
@ -79,6 +83,9 @@ internal sealed class CurrentGameRuntimeAdapter
public IGameRuntimeClock Clock => _view.Clock; public IGameRuntimeClock Clock => _view.Clock;
public IRuntimeEntityView Entities => _view.Entities; public IRuntimeEntityView Entities => _view.Entities;
public IRuntimeInventoryView Inventory => _view.Inventory; 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 IRuntimeChatView Chat => _view.Chat;
public IRuntimeMovementView Movement => _view.Movement; public IRuntimeMovementView Movement => _view.Movement;
public IRuntimePortalView Portal => _view.Portal; public IRuntimePortalView Portal => _view.Portal;
@ -92,6 +99,15 @@ internal sealed class CurrentGameRuntimeAdapter
IRuntimeChatCommands IGameRuntimeCommands.Chat => _commands; IRuntimeChatCommands IGameRuntimeCommands.Chat => _commands;
public IRuntimePortalCommands PortalCommands => _commands; public IRuntimePortalCommands PortalCommands => _commands;
IRuntimePortalCommands IGameRuntimeCommands.Portal => _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() => public RuntimeStateCheckpoint CaptureCheckpoint() =>
_view.CaptureCheckpoint(); _view.CaptureCheckpoint();

View file

@ -3,6 +3,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Items;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -20,7 +21,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter
IRuntimeCombatCommands, IRuntimeCombatCommands,
IRuntimeMovementCommands, IRuntimeMovementCommands,
IRuntimeChatCommands, IRuntimeChatCommands,
IRuntimePortalCommands IRuntimePortalCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands,
IRuntimeSocialCommands
{ {
private readonly LiveSessionController _session; private readonly LiveSessionController _session;
private readonly LiveSessionHost _sessionHost; private readonly LiveSessionHost _sessionHost;
@ -276,6 +281,340 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(RuntimeCommandStatus.Accepted); 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( private RuntimeCommandStatus Validate(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
bool requireWorld) bool requireWorld)
@ -294,6 +633,17 @@ internal sealed class CurrentGameRuntimeCommandAdapter
uint objectId = 0u) => uint objectId = 0u) =>
new(status, _view.Generation, objectId); 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) => private RuntimeSessionStartResult RejectedStart(RuntimeCommandStatus status) =>
new( new(
status == RuntimeCommandStatus.StaleGeneration status == RuntimeCommandStatus.StaleGeneration

View file

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

View file

@ -1747,6 +1747,16 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildEmote(seq, message)); 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) public void SendAddFriend(string name)
{ {
uint seq = NextGameActionSequence(); uint seq = NextGameActionSequence();

View file

@ -12,6 +12,7 @@ namespace AcDream.Core.Items;
public sealed class ItemManaState public sealed class ItemManaState
{ {
private readonly ConcurrentDictionary<uint, float> _manaByGuid = new(); private readonly ConcurrentDictionary<uint, float> _manaByGuid = new();
private long _revision;
/// <summary>Fires for every valid or invalid query response.</summary> /// <summary>Fires for every valid or invalid query response.</summary>
public event Action<uint /*guid*/, float /*fraction*/, bool /*valid*/>? ItemManaChanged; 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; _manaByGuid.TryGetValue(guid, out float percent) ? percent : 0f;
public bool HasMana(uint guid) => _manaByGuid.ContainsKey(guid); 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) public void OnQueryItemManaResponse(uint itemGuid, float manaPercent, bool valid)
{ {
@ -28,8 +34,13 @@ public sealed class ItemManaState
else else
_manaByGuid.TryRemove(itemGuid, out _); _manaByGuid.TryRemove(itemGuid, out _);
Interlocked.Increment(ref _revision);
ItemManaChanged?.Invoke(itemGuid, manaPercent, valid); 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 object _gate = new();
private readonly List<FriendEntry> _entries = 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() public IReadOnlyList<FriendEntry> Snapshot()
{ {
lock (_gate) return _entries.ToArray(); 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) public void Apply(FriendsUpdate update)
{ {
ArgumentNullException.ThrowIfNull(update); ArgumentNullException.ThrowIfNull(update);
@ -43,12 +60,17 @@ public sealed class FriendsState
} }
break; break;
} }
Interlocked.Increment(ref _revision);
} }
} }
public void Clear() 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 readonly object _gate = new();
private SquelchDatabase _database = SquelchDatabase.Empty; private SquelchDatabase _database = SquelchDatabase.Empty;
private long _revision;
public long Revision => Interlocked.Read(ref _revision);
public SquelchDatabase Snapshot() public SquelchDatabase Snapshot()
{ {
@ -14,7 +17,11 @@ public sealed class SquelchState
public void Replace(SquelchDatabase database) public void Replace(SquelchDatabase database)
{ {
ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(database);
lock (_gate) _database = database; lock (_gate)
{
_database = database;
Interlocked.Increment(ref _revision);
}
} }
/// <summary> /// <summary>
@ -25,7 +32,11 @@ public sealed class SquelchState
/// </summary> /// </summary>
public void Clear() 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); 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 public interface IGameRuntimeCommands
{ {
IRuntimeSessionCommands Session { get; } IRuntimeSessionCommands Session { get; }
@ -148,4 +256,12 @@ public interface IGameRuntimeCommands
IRuntimeChatCommands Chat { get; } IRuntimeChatCommands Chat { get; }
IRuntimePortalCommands Portal { 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, Movement,
Chat, Chat,
Portal, Portal,
InventoryState,
Spellbook,
Character,
Social,
} }
public readonly record struct RuntimeLifecycleDelta( public readonly record struct RuntimeLifecycleDelta(
@ -145,6 +149,19 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
System.Globalization.CultureInfo.InvariantCulture, System.Globalization.CultureInfo.InvariantCulture,
$"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" + $"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" +
$"containers={checkpoint.InventoryContainerCount};chat={checkpoint.ChatCount};" + $"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}:" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); $"{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 MaterializedEntityCount,
int InventoryObjectCount, int InventoryObjectCount,
int InventoryContainerCount, int InventoryContainerCount,
RuntimeInventoryStateSnapshot InventoryState,
RuntimeCharacterSnapshot Character,
RuntimeSocialSnapshot Social,
long ChatRevision, long ChatRevision,
int ChatCount, int ChatCount,
RuntimeMovementSnapshot Movement, RuntimeMovementSnapshot Movement,
@ -127,6 +130,12 @@ public interface IGameRuntimeView
IRuntimeInventoryView Inventory { get; } IRuntimeInventoryView Inventory { get; }
IRuntimeInventoryStateView InventoryState { get; }
IRuntimeCharacterView Character { get; }
IRuntimeSocialView Social { get; }
IRuntimeChatView Chat { get; } IRuntimeChatView Chat { get; }
IRuntimeMovementView Movement { get; } IRuntimeMovementView Movement { get; }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells; using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Gameplay;
@ -11,15 +12,27 @@ namespace AcDream.Runtime.Gameplay;
public sealed class RuntimeCharacterState : IDisposable public sealed class RuntimeCharacterState : IDisposable
{ {
private bool _disposed; private bool _disposed;
private long _characterRevision;
private long _spellbookRevision;
public RuntimeCharacterState(SpellTable? spellTable = null) public RuntimeCharacterState(SpellTable? spellTable = null)
{ {
Spellbook = new Spellbook(spellTable); Spellbook = new Spellbook(spellTable);
LocalPlayer = new LocalPlayerState(Spellbook); 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 Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; } public LocalPlayerState LocalPlayer { get; }
public RuntimeCharacterOptionsState Options { get; }
public RuntimeMovementSkillState MovementSkills { get; }
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
/// <summary> /// <summary>
@ -55,6 +68,8 @@ public sealed class RuntimeCharacterState : IDisposable
List<Exception>? failures = null; List<Exception>? failures = null;
Try(Spellbook.Clear, ref failures); Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures); Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures);
Try(MovementSkills.ResetSession, ref failures);
if (failures is not null) if (failures is not null)
{ {
throw new AggregateException( throw new AggregateException(
@ -68,9 +83,25 @@ public sealed class RuntimeCharacterState : IDisposable
if (_disposed) if (_disposed)
return; return;
ResetSession(); ResetSession();
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_disposed = true; _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) private static void Try(Action action, ref List<Exception>? failures)
{ {
try try
@ -82,4 +113,212 @@ public sealed class RuntimeCharacterState : IDisposable
(failures ??= []).Add(error); (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(); Friends = new FriendsState();
Squelch = new SquelchState(); Squelch = new SquelchState();
View = new CommunicationView(Chat); View = new CommunicationView(Chat);
SocialView = new CommunicationSocialView(
TurbineChat,
Friends,
Squelch);
} }
public ChatLog Chat { get; } public ChatLog Chat { get; }
@ -44,6 +48,7 @@ public sealed class RuntimeCommunicationState : IDisposable
public FriendsState Friends { get; } public FriendsState Friends { get; }
public SquelchState Squelch { get; } public SquelchState Squelch { get; }
public IRuntimeChatView View { get; } public IRuntimeChatView View { get; }
public IRuntimeSocialView SocialView { get; }
public IRuntimeCommunicationEventSource Events => _events; public IRuntimeCommunicationEventSource Events => _events;
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
@ -79,6 +84,61 @@ public sealed class RuntimeCommunicationState : IDisposable
public long Revision => chat.Revision; public long Revision => chat.Revision;
public int Count => chat.Count; 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 internal sealed class RuntimeCommunicationEventStream

View file

@ -21,6 +21,7 @@ public sealed class RuntimeInventoryState : IDisposable
ItemMana = new ItemManaState(); ItemMana = new ItemManaState();
Shortcuts = new ShortcutState(); Shortcuts = new ShortcutState();
Transactions = new InventoryTransactionState(_entityObjects.Objects); Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this);
} }
public ClientObjectTable Objects => _entityObjects.Objects; public ClientObjectTable Objects => _entityObjects.Objects;
@ -28,6 +29,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ItemManaState ItemMana { get; } public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; } public ShortcutState Shortcuts { get; }
public InventoryTransactionState Transactions { get; } public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
public void ResetExternalContainer() => ExternalContainers.Reset(); public void ResetExternalContainer() => ExternalContainers.Reset();
@ -66,6 +68,61 @@ public sealed class RuntimeInventoryState : IDisposable
(failures ??= []).Add(error); (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 public sealed class ShortcutState

View file

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

View file

@ -199,7 +199,6 @@ public sealed class InteractionRetainedUiCompositionTests
PlayerIdentity: null!, PlayerIdentity: null!,
PlayerController: null!, PlayerController: null!,
PlayerMode: null!, PlayerMode: null!,
CharacterOptions: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(), SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(), FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null, ExistingVitals: null,

View file

@ -10,6 +10,7 @@ using AcDream.App.UI.Layout;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition; namespace AcDream.App.Tests.Composition;
@ -67,6 +68,44 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions)); Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
} }
[Fact]
public void GameRuntimeCommandsCaptureTheExactCurrentGeneration()
{
var source = new DeferredGameRuntimeStateCommands();
var first = new RuntimeTarget(new RuntimeGenerationToken(7));
var second = new RuntimeTarget(new RuntimeGenerationToken(9));
Assert.False(source.IsInWorld);
Assert.Equal(
RuntimeCommandStatus.Inactive,
source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status);
IDisposable stale = source.Bind(first, first);
Assert.True(source.IsInWorld);
Assert.True(source.Advance(
RuntimeAdvancementKind.Skill,
6u,
100u).Accepted);
Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration);
Assert.Throws<InvalidOperationException>(() =>
source.Bind(second, second));
stale.Dispose();
using IDisposable current = source.Bind(second, second);
stale.Dispose();
Assert.True(source.AddFavorite(0, 2, 42u).Accepted);
Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration);
Assert.Equal(
RuntimeCommandStatus.Rejected,
source.RemoveShortcut(uint.MaxValue).Status);
source.Deactivate();
Assert.Equal(
RuntimeCommandStatus.Inactive,
source.RemoveShortcut(3u).Status);
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
}
[Fact] [Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot() public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{ {
@ -172,6 +211,109 @@ public sealed class InteractionUiRuntimeSourcesTests
public ICommandBus Commands { get; } = new LiveCommandBus(); public ICommandBus Commands { get; } = new LiveCommandBus();
} }
private sealed class RuntimeTarget
: IGameRuntimeView,
IGameRuntimeCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands
{
public RuntimeTarget(RuntimeGenerationToken generation)
{
Generation = generation;
}
public RuntimeGenerationToken LastGeneration { get; private set; }
public RuntimeGenerationToken Generation { get; }
public RuntimeLifecycleSnapshot Lifecycle =>
new(Generation, RuntimeLifecycleState.InWorld, 1u, true);
public IGameRuntimeClock Clock => null!;
public IRuntimeEntityView Entities => null!;
public IRuntimeInventoryView Inventory => null!;
public IRuntimeInventoryStateView InventoryState => null!;
public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!;
public IRuntimeMovementView Movement => null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this;
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this;
IRuntimeCharacterCommands IGameRuntimeCommands.Character => this;
IRuntimeSocialCommands IGameRuntimeCommands.Social => null!;
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) =>
Accepted(expectedGeneration, command.ObjectId);
public RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index) =>
Accepted(expectedGeneration);
public RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters) =>
Accepted(expectedGeneration);
public RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount) =>
Accepted(expectedGeneration, componentId);
public RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command) =>
Accepted(expectedGeneration, command.StatId);
public RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options) =>
Accepted(expectedGeneration);
private RuntimeCommandResult Accepted(
RuntimeGenerationToken generation,
uint objectId = 0u)
{
LastGeneration = generation;
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
generation,
objectId);
}
}
private sealed class SelectionQuery : IRetainedUiSelectionQuery private sealed class SelectionQuery : IRetainedUiSelectionQuery
{ {
public bool ShouldShowHealth(uint serverGuid) => true; public bool ShouldShowHealth(uint serverGuid) => true;

View file

@ -193,6 +193,11 @@ public sealed class GameplayInputCommandControllerTests
public IGameRuntimeClock Clock => throw new NotSupportedException(); public IGameRuntimeClock Clock => throw new NotSupportedException();
public IRuntimeEntityView Entities => throw new NotSupportedException(); public IRuntimeEntityView Entities => throw new NotSupportedException();
public IRuntimeInventoryView Inventory => throw new NotSupportedException(); public IRuntimeInventoryView Inventory => throw new NotSupportedException();
public IRuntimeInventoryStateView InventoryState =>
throw new NotSupportedException();
public IRuntimeCharacterView Character =>
throw new NotSupportedException();
public IRuntimeSocialView Social => throw new NotSupportedException();
public IRuntimeChatView Chat => throw new NotSupportedException(); public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException(); public IRuntimeMovementView Movement => throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException(); public IRuntimePortalView Portal => throw new NotSupportedException();

View file

@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -220,6 +221,35 @@ public sealed class LiveSessionCommandRouterTests
GC.KeepAlive(router); GC.KeepAlive(router);
} }
[Fact]
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
{
var shortcuts = new List<ShortcutEntry>();
var options = new List<uint>();
LiveSessionCommandRouter router = NewRouter(
addShortcut: shortcuts.Add,
setCharacterOptions: options.Add);
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(1, 0x80000001u, 0u)));
router.Activate();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(2, 0x80000002u, 0u)));
router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au));
router.Dispose();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(3, 0x80000003u, 0u)));
Assert.Collection(
shortcuts,
entry =>
{
Assert.Equal(2, entry.Index);
Assert.Equal(0x80000002u, entry.ObjectId);
});
Assert.Equal([0x50C4A54Au], options);
}
private static LiveSessionCommandRouter NewRouter( private static LiveSessionCommandRouter NewRouter(
ChatLog? chat = null, ChatLog? chat = null,
TurbineChatState? turbine = null, TurbineChatState? turbine = null,
@ -228,6 +258,8 @@ public sealed class LiveSessionCommandRouterTests
Action<string, string>? sendTell = null, Action<string, string>? sendTell = null,
Action<uint, string>? sendChannel = null, Action<uint, string>? sendChannel = null,
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null, Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
Action<ShortcutEntry>? addShortcut = null,
Action<uint>? setCharacterOptions = null,
Action<string>? log = null, Action<string>? log = null,
ClientCommandController.Bindings? clientBindings = null) => new( ClientCommandController.Bindings? clientBindings = null) => new(
new LiveSessionCommandBindings( new LiveSessionCommandBindings(
@ -239,7 +271,27 @@ public sealed class LiveSessionCommandRouterTests
sendTell ?? ((_, _) => { }), sendTell ?? ((_, _) => { }),
sendChannel ?? ((_, _) => { }), sendChannel ?? ((_, _) => { }),
sendTurbine ?? ((_, _, _, _, _, _) => { }), sendTurbine ?? ((_, _, _, _, _, _) => { }),
log)); AddShortcut: addShortcut ?? (_ => { }),
RemoveShortcut: _ => { },
AddFavorite: (_, _, _) => { },
RemoveFavorite: (_, _) => { },
SetSpellbookFilter: _ => { },
ForgetSpell: _ => { },
SetDesiredComponent: (_, _) => { },
ClearDesiredComponents: () => { },
RaiseAttribute: (_, _) => { },
RaiseVital: (_, _) => { },
RaiseSkill: (_, _) => { },
TrainSkill: (_, _) => { },
SetCharacterOptions: setCharacterOptions ?? (_ => { }),
AddFriend: _ => { },
RemoveFriend: _ => { },
ClearFriends: () => { },
RequestLegacyFriends: () => { },
ModifyCharacterSquelch: (_, _, _, _) => { },
ModifyAccountSquelch: (_, _) => { },
ModifyGlobalSquelch: (_, _) => { },
Log: log));
[MethodImpl(MethodImplOptions.NoInlining)] [MethodImpl(MethodImplOptions.NoInlining)]
private static (LiveSessionCommandRouter, WeakReference<object>) private static (LiveSessionCommandRouter, WeakReference<object>)

View file

@ -15,6 +15,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
@ -363,6 +364,157 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount); Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
} }
[Fact]
public void AdapterBorrowsExactJ4ViewsAndCheckpointState()
{
using var harness = new Harness();
harness.Character.Options.Replace(0x04000000u, 0x00948700u);
harness.Character.MovementSkills.Update(200, 175);
harness.Character.Spellbook.OnSpellLearned(42u);
harness.InventoryState.Shortcuts.Replace(
[
new ShortcutEntry(1, 0x80000001u, 0u),
]);
harness.Communication.Friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[
new FriendEntry(
0x50000003u,
"Borrowed",
true,
false,
[],
[]),
]));
Assert.Same(harness.Character.View, harness.Runtime.Character);
Assert.Same(
harness.InventoryState.View,
harness.Runtime.InventoryState);
Assert.Same(
harness.Communication.SocialView,
harness.Runtime.Social);
RuntimeStateCheckpoint checkpoint =
harness.Runtime.CaptureCheckpoint();
Assert.Equal(1, checkpoint.Character.LearnedSpellCount);
Assert.True(checkpoint.Character.MovementSkills.IsComplete);
Assert.Equal(1, checkpoint.InventoryState.ShortcutCount);
Assert.Equal(1, checkpoint.Social.FriendCount);
}
[Fact]
public void J4StateCommandsAreGenerationGatedAndReachCurrentTransport()
{
using var harness = new Harness();
var trace = new RuntimeTraceRecorder();
using IDisposable subscription = harness.Runtime.Subscribe(trace);
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
RuntimeGenerationToken generation = harness.Runtime.Generation;
IGameRuntimeCommands commands = harness.Runtime;
RuntimeCommandResult shortcut = commands.InventoryState.AddShortcut(
generation,
new RuntimeShortcutCommand(2, 0x80000001u, 0u));
RuntimeCommandResult favorite = commands.Spellbook.AddFavorite(
generation,
tabIndex: 0,
position: 0,
spellId: 42u);
RuntimeCommandResult filter = commands.Spellbook.SetFilter(
generation,
0x3FFEu);
RuntimeCommandResult desired = commands.Spellbook.SetDesiredComponent(
generation,
0x68000001u,
10u);
RuntimeCommandResult advancement = commands.Character.Advance(
generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Skill,
StatId: 6u,
Cost: 500u));
RuntimeCommandResult options = commands.Character.SetOptions1(
generation,
0x50C4A54Au);
RuntimeCommandResult friend = commands.Social.Execute(
generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Add,
Name: "Runtime Friend"));
RuntimeCommandResult squelch = commands.Social.Execute(
generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Global,
Add: true,
MessageType: 3u));
Assert.All(
new[]
{
shortcut,
favorite,
filter,
desired,
advancement,
options,
friend,
squelch,
},
static result => Assert.True(result.Accepted));
Assert.Contains(
harness.Commands.Published,
static command => command is AddShortcutRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is AddFavoriteRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetSpellbookFilterRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetDesiredComponentRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is RaiseSkillRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetCharacterOptionsRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is AddFriendRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is ModifyGlobalSquelchRuntimeCmd);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.InventoryState);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Spellbook);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Character);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Social);
int published = harness.Commands.Published.Count;
RuntimeCommandResult stale = commands.Character.SetOptions1(
new RuntimeGenerationToken(generation.Value - 1),
0u);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
Assert.Equal(published, harness.Commands.Published.Count);
}
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new() private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
{ {
ObjectId = spawn.Guid, ObjectId = spawn.Guid,
@ -407,6 +559,8 @@ public sealed class CurrentGameRuntimeAdapterTests
{ {
Identity = new LocalPlayerIdentityState(); Identity = new LocalPlayerIdentityState();
EntityObjects = new RuntimeEntityObjectLifetime(); EntityObjects = new RuntimeEntityObjectLifetime();
InventoryState = new RuntimeInventoryState(EntityObjects);
Character = new RuntimeCharacterState();
Entities = new LiveEntityRuntime( Entities = new LiveEntityRuntime(
new GpuWorldState(), new GpuWorldState(),
new NoopEntityResources(), new NoopEntityResources(),
@ -448,7 +602,9 @@ public sealed class CurrentGameRuntimeAdapterTests
Options = LiveOptions(); Options = LiveOptions();
Commands = new RecordingCommandRouting(); Commands = new RecordingCommandRouting();
_session = new LiveSessionController(new SessionOperations()); Transport = new TestTransport();
_session = new LiveSessionController(
new SessionOperations(Transport));
Host = CreateHost(_session, Commands, Identity); Host = CreateHost(_session, Commands, Identity);
Runtime = new CurrentGameRuntimeAdapter( Runtime = new CurrentGameRuntimeAdapter(
_session, _session,
@ -457,6 +613,8 @@ public sealed class CurrentGameRuntimeAdapterTests
Identity, Identity,
Entities, Entities,
EntityObjects, EntityObjects,
InventoryState,
Character,
Communication, Communication,
new LocalPlayerControllerSlot(), new LocalPlayerControllerSlot(),
WorldReveal, WorldReveal,
@ -470,6 +628,8 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeOptions Options { get; } public RuntimeOptions Options { get; }
public LocalPlayerIdentityState Identity { get; } public LocalPlayerIdentityState Identity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; } public RuntimeEntityObjectLifetime EntityObjects { get; }
public RuntimeInventoryState InventoryState { get; }
public RuntimeCharacterState Character { get; }
public LiveEntityRuntime Entities { get; } public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; } public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; } public RuntimeCommunicationState Communication { get; }
@ -481,12 +641,15 @@ public sealed class CurrentGameRuntimeAdapterTests
public UpdateFrameClock Clock { get; } public UpdateFrameClock Clock { get; }
public WorldRevealCoordinator WorldReveal { get; } public WorldRevealCoordinator WorldReveal { get; }
public RecordingCommandRouting Commands { get; } public RecordingCommandRouting Commands { get; }
public TestTransport Transport { get; }
public LiveSessionHost Host { get; } public LiveSessionHost Host { get; }
public CurrentGameRuntimeAdapter Runtime { get; } public CurrentGameRuntimeAdapter Runtime { get; }
public void Dispose() public void Dispose()
{ {
Runtime.Dispose(); Runtime.Dispose();
Character.Dispose();
InventoryState.Dispose();
Communication.Dispose(); Communication.Dispose();
_session.Dispose(); _session.Dispose();
_items.Dispose(); _items.Dispose();
@ -644,13 +807,14 @@ public sealed class CurrentGameRuntimeAdapterTests
Physics: physics); Physics: physics);
} }
private sealed class SessionOperations : ILiveSessionOperations private sealed class SessionOperations(TestTransport transport)
: ILiveSessionOperations
{ {
public IPEndPoint ResolveEndpoint(string host, int port) => public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port); new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) => public WorldSession CreateSession(IPEndPoint endpoint) =>
new(endpoint, new TestTransport()); new(endpoint, transport);
public void Connect(WorldSession session, string user, string password) public void Connect(WorldSession session, string user, string password)
{ {
@ -686,14 +850,18 @@ public sealed class CurrentGameRuntimeAdapterTests
public void DisposeSession(WorldSession session) => session.Dispose(); public void DisposeSession(WorldSession session) => session.Dispose();
} }
private sealed class TestTransport : IWorldSessionTransport internal sealed class TestTransport : IWorldSessionTransport
{ {
public List<byte[]> Sent { get; } = [];
public void Send(ReadOnlySpan<byte> datagram) public void Send(ReadOnlySpan<byte> datagram)
{ {
Sent.Add(datagram.ToArray());
} }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
{ {
Sent.Add(datagram.ToArray());
} }
public int Receive( public int Receive(

View file

@ -81,6 +81,37 @@ public sealed class RuntimeCharacterOwnershipTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
[Fact]
public void AppCharacterOptionAndMovementSkillOwnersWereDeleted()
{
string gameWindow = ReadSource("Rendering", "GameWindow.cs");
string sources = ReadSource(
"Composition",
"InteractionUiRuntimeSources.cs");
string playerState = ReadSource("Input", "LocalPlayerRuntimeState.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string router = ReadRuntimeSource(
"Session",
"LiveSessionEventRouter.cs");
Assert.DoesNotContain(
"PlayerCharacterOptionsState",
gameWindow + sources + session,
StringComparison.Ordinal);
Assert.DoesNotContain(
"LocalPlayerSkillState",
gameWindow + playerState + session,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.Options.Replace",
router,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.MovementSkills.Update",
router,
StringComparison.Ordinal);
}
private static string ReadSource(params string[] relative) => private static string ReadSource(params string[] relative) =>
File.ReadAllText(Path.Combine( File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.App", .. relative])); [FindRepositoryRoot(), "src", "AcDream.App", .. relative]));

View file

@ -1,3 +1,6 @@
using System.Reflection;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests; namespace AcDream.Runtime.Tests;
public sealed class GameRuntimeContractTests public sealed class GameRuntimeContractTests
@ -137,4 +140,68 @@ public sealed class GameRuntimeContractTests
Assert.Equal("General|Grey|hello", entry.Text); Assert.Equal("General|Grey|hello", entry.Text);
}); });
} }
[Fact]
public void TraceRecorderIncludesJ4GameplayStateInCheckpoint()
{
var recorder = new RuntimeTraceRecorder();
var stamp = new RuntimeEventStamp(new(3), 1, 8);
var checkpoint = new RuntimeStateCheckpoint(
new RuntimeGenerationToken(3),
RuntimeLifecycleState.InWorld,
FrameNumber: 8,
EntityCount: 2,
MaterializedEntityCount: 2,
InventoryObjectCount: 1,
InventoryContainerCount: 1,
new RuntimeInventoryStateSnapshot(
0u, 0u, 0, true, null, 2, 4, 1, 3),
new RuntimeCharacterSnapshot(
CharacterRevision: 5,
SpellbookRevision: 6,
default,
default,
LearnedSpellCount: 7,
ActiveEnchantmentCount: 0,
DesiredComponentCount: 0,
SkillCount: 8,
SpellbookFilters: 0x3FFFu),
new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0),
ChatRevision: 12,
ChatCount: 13,
default,
default);
recorder.AddCheckpoint(stamp, checkpoint);
RuntimeTraceEntry entry = Assert.Single(recorder.Entries);
Assert.Contains("inventory-state=2:4:1:3", entry.Text);
Assert.Contains("character=5:6:7:8:0:0", entry.Text);
Assert.Contains("social=9:10:11", entry.Text);
}
[Fact]
public void J4GameplayOwnersHaveNoStaticMutableSessionState()
{
Type[] owners =
[
typeof(RuntimeCommunicationState),
typeof(RuntimeInventoryState),
typeof(RuntimeCharacterState),
typeof(RuntimeCharacterOptionsState),
typeof(RuntimeMovementSkillState),
];
foreach (Type owner in owners)
{
FieldInfo[] mutable = owner
.GetFields(
BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic)
.Where(static field => !field.IsLiteral)
.ToArray();
Assert.Empty(mutable);
}
}
} }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Player;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay; namespace AcDream.Runtime.Tests.Gameplay;
@ -152,4 +153,105 @@ public sealed class RuntimeCharacterStateTests
Assert.True(state.IsDisposed); Assert.True(state.IsDisposed);
} }
[Fact]
public void OwnsRetailCharacterOptionsAndMovementSkillProjection()
{
using var state = new RuntimeCharacterState();
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions1,
state.Options.Options1);
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions2,
state.Options.Options2);
Assert.False(state.MovementSkills.IsComplete);
state.Options.Replace(0x04000000u, 0x12345678u);
state.MovementSkills.Update(runSkill: 210, jumpSkill: -1);
state.MovementSkills.Update(runSkill: -1, jumpSkill: 165);
Assert.True(state.Options.DragItemOnPlayerOpensSecureTrade);
Assert.Equal(0x12345678u, state.Options.Options2);
Assert.Equal(
new RuntimeMovementSkillSnapshot(
210,
165,
state.MovementSkills.Revision),
state.MovementSkills.Snapshot);
Assert.True(state.MovementSkills.IsComplete);
state.ResetSession();
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions1,
state.Options.Options1);
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions2,
state.Options.Options2);
Assert.Equal(-1, state.MovementSkills.RunSkill);
Assert.Equal(-1, state.MovementSkills.JumpSkill);
}
[Fact]
public void CharacterViewBorrowsExactOwnersWithoutReconstructedState()
{
using var state = new RuntimeCharacterState();
state.LocalPlayer.OnAttributeUpdate(1u, 40u, 10u, 500u);
state.LocalPlayer.OnVitalUpdate(7u, 60u, 20u, 700u, 75u);
state.LocalPlayer.OnSkillUpdate(
6u,
30u,
2u,
800u,
10u,
0u,
5d,
12u);
state.Spellbook.OnSpellLearned(42u);
state.Spellbook.SetFavorite(0, 0, 42u);
state.Spellbook.SetDesiredComponent(0x68000001u, 11u);
RuntimeCharacterSnapshot summary = state.View.Snapshot;
Assert.Equal(1, summary.LearnedSpellCount);
Assert.Equal(1, summary.DesiredComponentCount);
Assert.Equal(1, summary.SkillCount);
Assert.True(state.View.KnowsSpell(42u));
Assert.True(state.View.TryGetFavorite(0, 0, out uint favorite));
Assert.Equal(42u, favorite);
Assert.True(state.View.TryGetDesiredComponent(
0x68000001u,
out uint desired));
Assert.Equal(11u, desired);
Assert.True(state.View.TryGetAttribute(
(int)LocalPlayerState.AttributeKind.Strength,
out RuntimeAttributeSnapshot attribute));
Assert.Equal(50u, attribute.Current);
Assert.True(state.View.TryGetVital(
(int)LocalPlayerState.VitalKind.Health,
out RuntimeVitalSnapshot vital));
Assert.Equal(75u, vital.Current);
Assert.True(state.View.TryGetSkill(6u, out RuntimeSkillSnapshot skill));
Assert.Equal(52u, skill.CurrentLevel);
}
[Fact]
public void TwoRuntimeInstancesIsolateOptionsSkillsAndViewRevisions()
{
using var first = new RuntimeCharacterState();
using var second = new RuntimeCharacterState();
first.Options.Replace(1u, 2u);
first.MovementSkills.Update(100, 200);
first.Spellbook.OnSpellLearned(9u);
Assert.NotEqual(
first.View.Snapshot.Options,
second.View.Snapshot.Options);
Assert.True(first.View.Snapshot.MovementSkills.IsComplete);
Assert.False(second.View.Snapshot.MovementSkills.IsComplete);
Assert.True(first.View.Snapshot.SpellbookRevision > 0);
Assert.Equal(0, second.View.Snapshot.SpellbookRevision);
}
} }

View file

@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCommunicationStateTests public sealed class RuntimeCommunicationStateTests
{ {
[Fact]
public void SocialViewBorrowsFriendsAndSquelchOwners()
{
using var state = new RuntimeCommunicationState();
state.Friends.Apply(new AcDream.Core.Social.FriendsUpdate(
AcDream.Core.Social.FriendsUpdateType.Full,
[
new AcDream.Core.Social.FriendEntry(
0x50000001u,
"Friend",
Online: true,
AppearOffline: false,
Friends: [],
FriendOf: []),
]));
state.Squelch.Replace(new AcDream.Core.Social.SquelchDatabase(
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["account"] = 1u,
},
new Dictionary<uint, AcDream.Core.Social.SquelchInfo>(),
new AcDream.Core.Social.SquelchInfo(
string.Empty,
false,
new HashSet<uint> { 3u })));
RuntimeSocialSnapshot snapshot = state.SocialView.Snapshot;
Assert.Equal(1, snapshot.FriendCount);
Assert.Equal(1, snapshot.SquelchedAccountCount);
Assert.Equal(1, snapshot.GlobalSquelchTypeCount);
Assert.True(state.SocialView.TryGetFriend(
0x50000001u,
out RuntimeFriendSnapshot friend));
Assert.Equal("Friend", friend.Name);
Assert.True(friend.Online);
}
[Fact] [Fact]
public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries() public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries()
{ {

View file

@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeInventoryStateTests public sealed class RuntimeInventoryStateTests
{ {
[Fact]
public void ViewBorrowsTransactionShortcutAndManaOwners()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
inventory.Shortcuts.Replace(
[
new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u),
new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u),
]);
inventory.ItemMana.OnQueryItemManaResponse(
0x80000001u,
0.75f,
valid: true);
inventory.Transactions.IncrementBusyCount();
RuntimeInventoryStateSnapshot snapshot = inventory.View.Snapshot;
Assert.Equal(2, snapshot.ShortcutCount);
Assert.Equal(1, snapshot.ItemManaCount);
Assert.Equal(1, snapshot.BusyCount);
Assert.False(snapshot.CanBeginRequest);
Assert.True(inventory.View.TryGetShortcut(
3,
out RuntimeShortcutSnapshot itemShortcut));
Assert.Equal(
new RuntimeShortcutSnapshot(3, 0x80000001u, 0u),
itemShortcut);
Assert.True(inventory.View.TryGetShortcut(
4,
out RuntimeShortcutSnapshot spellShortcut));
Assert.Equal(new RuntimeShortcutSnapshot(4, 0u, 42u), spellShortcut);
Assert.True(inventory.View.TryGetItemMana(
itemShortcut.ObjectId,
out float fraction));
Assert.Equal(0.75f, fraction);
}
[Fact] [Fact]
public void OwnsOneGameplayGraphOverExactEntityObjectTable() public void OwnsOneGameplayGraphOverExactEntityObjectTable()
{ {

View file

@ -242,7 +242,6 @@ public sealed class LiveSessionEventRouterTests
OnSkillsUpdated: null, OnSkillsUpdated: null,
OnConfirmationRequest: null, OnConfirmationRequest: null,
OnConfirmationDone: null, OnConfirmationDone: null,
OnCharacterOptions: null,
ClientTime: () => 0d); ClientTime: () => 0d);
private static LiveSocialSessionBindings NewSocialBindings( private static LiveSocialSessionBindings NewSocialBindings(