refactor(runtime): expose canonical gameplay state

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

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

View file

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

View file

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

View file

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