feat(headless): complete portable single-session host

This commit is contained in:
Erik 2026-07-27 07:36:53 +02:00
parent fbebb91848
commit f8cb840fb1
30 changed files with 3571 additions and 32 deletions

View file

@ -0,0 +1,427 @@
using AcDream.Core.Net;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
/// <summary>
/// Typed command surface for a presentation-free Runtime host. K1 supplies
/// session, local chat, and recall operations; later Slice-K work extends the
/// same adapter rather than introducing a second bot command model.
/// </summary>
public sealed class DirectGameRuntimeCommandAdapter
: IGameRuntimeCommands,
IRuntimeSessionCommands,
IRuntimeSelectionCommands,
IRuntimeCombatCommands,
IRuntimeMagicCommands,
IRuntimeMovementCommands,
IRuntimeChatCommands,
IRuntimePortalCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands,
IRuntimeSocialCommands
{
private sealed class CommandRoute(
DirectGameRuntimeCommandAdapter owner,
WorldSession session,
RuntimeGenerationToken generation)
: ILiveSessionCommandRouting
{
private bool _active;
public void Activate()
{
if (_active)
return;
owner.Activate(session, generation, this);
_active = true;
}
public void Dispose()
{
owner.Deactivate(this);
_active = false;
}
}
private readonly object _gate = new();
private readonly GameRuntime _runtime;
private readonly IRuntimeSessionCommands _sessionCommands;
private WorldSession? _session;
private CommandRoute? _route;
private RuntimeGenerationToken _routeGeneration;
public DirectGameRuntimeCommandAdapter(
GameRuntime runtime,
IRuntimeSessionCommands sessionCommands)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_sessionCommands = sessionCommands
?? throw new ArgumentNullException(nameof(sessionCommands));
}
public IRuntimeSessionCommands Session => this;
public IRuntimeSelectionCommands Selection => this;
public IRuntimeCombatCommands Combat => this;
public IRuntimeMagicCommands Magic => this;
public IRuntimeMovementCommands Movement => this;
public IRuntimeChatCommands Chat => this;
public IRuntimePortalCommands Portal => this;
public IRuntimeInventoryStateCommands InventoryState => this;
public IRuntimeSpellbookCommands Spellbook => this;
public IRuntimeCharacterCommands Character => this;
public IRuntimeSocialCommands Social => this;
public ILiveSessionCommandRouting CreateRoute(WorldSession session)
{
ArgumentNullException.ThrowIfNull(session);
return new CommandRoute(this, session, _runtime.Generation);
}
public RuntimeSessionStartResult Start(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _runtime.Lifecycle.State;
RuntimeSessionStartResult result =
_sessionCommands.Start(expectedGeneration);
_runtime.EventSink.EmitLifecycle(
previous,
_runtime.Lifecycle.State);
return result;
}
public RuntimeSessionStartResult Reconnect(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _runtime.Lifecycle.State;
RuntimeSessionStartResult result =
_sessionCommands.Reconnect(expectedGeneration);
_runtime.EventSink.EmitLifecycle(
previous,
_runtime.Lifecycle.State);
return result;
}
public RuntimeTeardownAcknowledgement Stop(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _runtime.Lifecycle.State;
RuntimeTeardownAcknowledgement result =
_sessionCommands.Stop(expectedGeneration);
_runtime.EventSink.EmitLifecycle(
previous,
_runtime.Lifecycle.State);
return result;
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeChatCommand command)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (string.IsNullOrWhiteSpace(command.Text))
return EmitUnsupported(
RuntimeCommandDomain.Chat,
(int)command.Channel,
RuntimeCommandStatus.Rejected);
switch (command.Channel)
{
case RuntimeChatChannel.Say:
session!.SendTalk(command.Text);
break;
case RuntimeChatChannel.Tell
when !string.IsNullOrWhiteSpace(command.TargetName):
session!.SendTell(command.TargetName, command.Text);
break;
default:
return EmitUnsupported(
RuntimeCommandDomain.Chat,
(int)command.Channel);
}
_runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Chat,
(int)command.Channel,
RuntimeCommandStatus.Accepted,
text: command.Text);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimePortalCommand command)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
switch (command)
{
case RuntimePortalCommand.RecallLifestone:
session!.SendTeleportToLifestone();
break;
case RuntimePortalCommand.RecallMarketplace:
session!.SendTeleportToMarketplace();
break;
case RuntimePortalCommand.RecallHouse:
session!.SendTeleportToHouse();
break;
case RuntimePortalCommand.RecallMansion:
session!.SendTeleportToMansion();
break;
default:
return EmitUnsupported(
RuntimeCommandDomain.Portal,
(int)command);
}
_runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Portal,
(int)command,
RuntimeCommandStatus.Accepted);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeSelectionCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Selection,
(int)command);
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Combat,
(int)command);
public RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
in RuntimeCombatAttackInput command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Combat,
(int)command.Command);
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeMagicCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Magic,
operation: 0,
command.SpellId);
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Movement,
(int)command);
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.InventoryState,
operation: 0,
command.ObjectId);
public RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.InventoryState,
operation: 1);
public RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 0,
spellId);
public RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 1,
spellId);
public RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 2);
public RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 3,
spellId);
public RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 4,
componentId);
public RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Spellbook,
operation: 5);
public RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Character,
(int)command.Kind,
command.StatId);
public RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Character,
operation: 4);
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeFriendCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Social,
(int)command.Kind,
command.CharacterId);
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeSquelchCommand command) =>
Unsupported(
expectedGeneration,
RuntimeCommandDomain.Social,
(int)command.Scope,
command.CharacterId);
private RuntimeCommandResult Unsupported(
RuntimeGenerationToken expectedGeneration,
RuntimeCommandDomain domain,
int operation,
uint primaryObjectId = 0u)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out _);
return gate == RuntimeCommandStatus.Accepted
? EmitUnsupported(
domain,
operation,
RuntimeCommandStatus.Unsupported,
primaryObjectId)
: Result(gate);
}
private RuntimeCommandResult EmitUnsupported(
RuntimeCommandDomain domain,
int operation,
RuntimeCommandStatus status = RuntimeCommandStatus.Unsupported,
uint primaryObjectId = 0u)
{
_runtime.EventSink.EmitCommand(
domain,
operation,
status,
primaryObjectId);
return Result(status, primaryObjectId);
}
private RuntimeCommandStatus Validate(
RuntimeGenerationToken expectedGeneration,
out WorldSession? session)
{
lock (_gate)
{
if (expectedGeneration != _runtime.Generation
|| (_route is not null
&& _routeGeneration != expectedGeneration))
{
session = null;
return RuntimeCommandStatus.StaleGeneration;
}
session = _session;
return _route is not null
&& session is not null
&& _runtime.Session.IsInWorld
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Inactive;
}
}
private RuntimeCommandResult Result(
RuntimeCommandStatus status,
uint objectId = 0u) =>
new(status, _runtime.Generation, objectId);
private void Activate(
WorldSession session,
RuntimeGenerationToken generation,
CommandRoute route)
{
lock (_gate)
{
if (_route is not null)
{
throw new InvalidOperationException(
"A direct Runtime command route is already active.");
}
_session = session;
_routeGeneration = generation;
_route = route;
}
}
private void Deactivate(CommandRoute route)
{
lock (_gate)
{
if (!ReferenceEquals(_route, route))
return;
_route = null;
_session = null;
_routeGeneration = default;
}
}
}

View file

@ -2,12 +2,18 @@ using AcDream.Core.Net;
namespace AcDream.Runtime.Session;
public sealed record LiveSessionCharacterSelector(
int? ActiveIndex = null,
uint? CharacterId = null,
string? CharacterName = null);
public sealed record LiveSessionConnectOptions(
bool Enabled,
string Host,
int Port,
string User,
string Password);
string Password,
LiveSessionCharacterSelector? Character = null);
public interface IRuntimeLiveSessionFramePhase
{

View file

@ -556,7 +556,10 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is null
|| !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected))
|| !TrySelectCharacter(
characters,
options.Character,
out CharacterList.Selection selected))
{
Console.WriteLine("live: no available characters on account; disconnecting");
StopCore();
@ -755,6 +758,62 @@ public sealed class LiveSessionController
private LiveSessionStartResult ConnectedResult()
=> new(LiveSessionStartStatus.Connected, _activeSelection);
private static bool TrySelectCharacter(
CharacterList.Parsed characters,
LiveSessionCharacterSelector? selector,
out CharacterList.Selection selection)
{
ArgumentNullException.ThrowIfNull(characters);
if (selector is null)
{
return CharacterList.TrySelectFirstAvailable(
characters,
out selection);
}
int selectorCount =
(selector.ActiveIndex.HasValue ? 1 : 0)
+ (selector.CharacterId.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.CharacterName) ? 1 : 0);
if (selectorCount != 1)
{
selection = default;
return false;
}
for (int index = 0; index < characters.Characters.Count; index++)
{
CharacterList.Character character =
characters.Characters[index];
if (!CharacterList.IsAvailableActiveIdentity(character))
continue;
if (selector.ActiveIndex is { } requestedIndex
&& requestedIndex != index)
{
continue;
}
if (selector.CharacterId is { } requestedId
&& requestedId != character.Id)
{
continue;
}
if (selector.CharacterName is { } requestedName
&& !string.Equals(
requestedName,
character.Name,
StringComparison.OrdinalIgnoreCase))
{
continue;
}
selection = new CharacterList.Selection(index, character);
return true;
}
selection = default;
return false;
}
private void ThrowIfDisposing()
{
if (_disposeRequested || _disposed)

View file

@ -0,0 +1,291 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Session;
/// <summary>
/// Presentation-free inbound entity route for a direct Runtime host. It
/// applies the same canonical identity, timestamp, object-table, and transit
/// owners used by the graphical route without constructing App hydration,
/// rendering, animation, or effect projections.
/// </summary>
public sealed class RuntimeLiveEntitySessionController
{
private readonly GameRuntime _runtime;
private readonly WorldSession _session;
private readonly Action<string> _log;
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
WorldSession session,
Action<string>? log = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_session = session ?? throw new ArgumentNullException(nameof(session));
_log = log ?? (_ => { });
}
public LiveEntitySessionSink CreateSink() => new(
OnSpawned,
OnDeleted,
OnPickedUp,
OnMotionUpdated,
OnPositionUpdated,
OnVectorUpdated,
OnStateUpdated,
OnParentUpdated,
OnTeleportStarted,
OnAppearanceUpdated,
_ => { },
_ => { });
private RuntimeEntityObjectLifetime Entities =>
_runtime.EntityObjects;
private void OnSpawned(WorldSession.EntitySpawn spawn)
{
RuntimeEntityRegistrationResult registration =
Entities.RegisterEntity(spawn);
if (registration.Canonical is not { } canonical)
return;
ulong integrationVersion = canonical.CreateIntegrationVersion;
_ = Entities.ApplyAcceptedSpawn(
canonical,
integrationVersion,
canonical.Snapshot,
replaceGeneration:
registration.Inbound.Disposition
is CreateObjectTimestampDisposition.NewGeneration);
}
private void OnDeleted(DeleteObject.Parsed delete)
{
if (delete.Guid == _runtime.PlayerIdentity.ServerGuid
|| !Entities.TryAcceptDelete(
delete,
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance))
{
return;
}
Entities.CompleteAcceptedDelete(acceptance);
if (acceptance.RetiredCanonical is { } retired)
{
Exception? failure = Entities.RetireCanonicalOnly(retired);
if (failure is not null)
throw failure;
}
}
private void OnPickedUp(PickupEvent.Parsed pickup) =>
_ = Entities.TryApplyPickup(
pickup,
acknowledgeProjection: null,
out _);
private void OnMotionUpdated(
WorldSession.EntityMotionUpdate update)
{
bool isLocal =
update.Guid == _runtime.PlayerIdentity.ServerGuid;
_ = Entities.TryApplyMotion(
update,
retainPayload: !isLocal || !update.IsAutonomous,
acknowledgeProjection: null,
out _,
out _);
}
private void OnPositionUpdated(
WorldSession.EntityPositionUpdate update)
{
bool isLocal =
update.Guid == _runtime.PlayerIdentity.ServerGuid;
bool known = Entities.TryApplyPosition(
update,
isLocal,
forcePositionRotation: null,
currentLocalVelocity: null,
projectionRequiresTeleportHook: false,
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
out _,
out AcceptedPhysicsTimestamps timestamps);
if (!known
|| !isLocal
|| disposition is PositionTimestampDisposition.Rejected)
{
return;
}
var position = update.Position;
var destination = new RuntimeTeleportDestination(
update.Guid,
update.InstanceSequence,
update.PositionSequence,
update.TeleportSequence,
update.ForcePositionSequence,
new Position(
position.LandblockId,
new Vector3(
position.PositionX,
position.PositionY,
position.PositionZ),
new Quaternion(
position.RotationX,
position.RotationY,
position.RotationZ,
position.RotationW)));
_runtime.TransitOwner.OfferTeleportDestination(
destination,
timestamps.TeleportAdvanced);
TryCompletePortal();
}
private void OnVectorUpdated(VectorUpdate.Parsed update) =>
_ = Entities.TryApplyVector(
update,
acknowledgeProjection: null,
out _);
private void OnStateUpdated(SetState.Parsed update) =>
_ = Entities.TryApplyState(
update,
acknowledgeProjection: null,
out _,
out _);
private void OnParentUpdated(ParentEvent.Parsed update) =>
_ = Entities.TryApplyParent(
update,
acknowledgeProjection: null,
out _);
private void OnTeleportStarted(uint rawSequence)
{
ushort sequence = unchecked((ushort)rawSequence);
RuntimeWorldTransitState transit = _runtime.TransitOwner;
if (!transit.TryQueueTeleportStart(sequence))
return;
if (!transit.ActivateQueuedTeleport())
{
throw new InvalidOperationException(
"Runtime rejected its queued headless teleport activation.");
}
TryCompletePortal();
}
private void OnAppearanceUpdated(ObjDescEvent.Parsed update) =>
_ = Entities.TryApplyObjDesc(
update,
acknowledgeProjection: null,
out _);
private void TryCompletePortal()
{
RuntimeWorldTransitState transit = _runtime.TransitOwner;
if (!transit.TryGetAcceptedTeleportDestination(
out RuntimeTeleportDestination destination)
|| !transit.TryBeginPortalReveal(
destination.TeleportSequence,
destination.CellId,
out long generation))
{
return;
}
if (!transit.TryRegisterHostProjection(
generation,
destination.CellId,
out RuntimeWorldHostProjectionToken projection))
{
throw new InvalidOperationException(
"Runtime rejected the headless portal projection.");
}
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
if (!transit.AcknowledgeDestinationReadiness(
new RuntimeDestinationReadiness(
generation,
destination.CellId,
indoor,
IsUnhydratable: false,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true)))
{
throw new InvalidOperationException(
"Runtime rejected headless destination readiness.");
}
if (!transit.AcknowledgePortalMaterialized(
generation,
destination.TeleportSequence,
destination.CellId))
{
throw new InvalidOperationException(
"Runtime rejected headless portal materialization.");
}
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected);
if (!transit.RequireDestinationReservationRelease(projection))
{
throw new InvalidOperationException(
"Runtime rejected headless destination release.");
}
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.DestinationReservationReleased);
if (!transit.AcknowledgeWorldViewportVisible(generation)
|| !transit.Complete(generation))
{
throw new InvalidOperationException(
"Runtime rejected headless portal completion.");
}
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage.TerminalProjected);
_session.SendGameAction(GameActionLoginComplete.Build());
transit.EndTeleport();
_log(
$"headless: portal complete generation={generation} "
+ $"cell=0x{destination.CellId:X8}");
}
private static void Acknowledge(
RuntimeWorldTransitState transit,
RuntimeWorldHostProjectionToken projection,
RuntimeWorldHostAcknowledgementStage stage)
{
if (!transit.AcknowledgeHostProjection(
new RuntimeWorldHostAcknowledgement(
projection,
stage)))
{
throw new InvalidOperationException(
$"Runtime rejected headless host acknowledgement {stage}.");
}
}
}