acdream/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs
Erik 369729f06a feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners
Two new sibling Runtime owners under GameRuntime per the Slice-J
pattern (D2): RuntimeFellowshipState is session-scoped (a new
RuntimeGenerationResetStage.Fellowship clears it at every generation
reset, matching the ExternalContainer precedent); RuntimeAllegianceState
survives reconnect behind a HasServerSeed-style one-way latch and
participates in NO reset stage (its data persists like a real
disconnect does not sever allegiance membership).

Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs-
other quit/dismiss removal (self clears the whole snapshot), disband
clear, and retail's leader hand-off rule for the Quit button
(RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT
disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B
§2.5/§3.6).

Allegiance: seeded by AllegianceUpdate (0x0020, always self) and,
self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse
(0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list
directly (AllegianceTree was deleted at FA1 -- nothing left to wrap).

Both apply the full 8-edit J-owner template: construction + fault
points + Owner/View properties + CaptureOwnership + a new
GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed,
stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion +
RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/
IRuntimeAllegianceCommands added to IGameRuntimeCommands and
implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver
member added (D2) -- consumers poll Snapshot.Revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:31:54 +02:00

936 lines
35 KiB
C#

using System.Net;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class DirectGameRuntimeCommandAdapterTests
{
[Fact]
public void DirectRouteSendsTypedChatAndPortalAndRejectsOldGeneration()
{
var operations = new FixtureSessionOperations();
var gameplay = new FixtureGameplayOperations();
using var runtime = new GameRuntime(new GameRuntimeDependencies(
gameplay,
gameplay,
gameplay,
gameplay,
SessionOperations: operations));
gameplay.Bind(runtime);
var resetHost = new FixtureResetHost();
DirectGameRuntimeCommandAdapter? adapter = null;
LiveSessionConnectOptions options = new(
true,
"127.0.0.1",
9000,
"account",
"password");
var live = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
_ => new FixtureEventRoute(),
session => adapter!.CreateRoute(session)),
generation => runtime.ResetGeneration(
generation,
resetHost),
new LiveSessionSelectionBindings(
id => runtime.PlayerIdentity.ServerGuid = id,
_ => { },
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
_ => { },
_ => { },
runtime.ActionOwner.Combat.Clear),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
var trace = new RuntimeTraceRecorder();
using IDisposable subscription = runtime.Subscribe(trace);
RuntimeSessionStartResult started =
adapter.Session.Start(runtime.Generation);
RuntimeGenerationToken firstGeneration = runtime.Generation;
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture =
body => gameActions.Add(body);
const uint selectedObject = 0x70000001u;
runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
{
ObjectId = selectedObject,
Type = ItemType.Misc,
});
RuntimeCommandResult chat = adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"hello"));
RuntimeCommandResult portal = adapter.Portal.Execute(
runtime.Generation,
RuntimePortalCommand.RecallLifestone);
runtime.CommunicationOwner.TurbineChat.OnChannelsReceived(
allegianceRoom: 0x10u,
generalRoom: 0x11u,
tradeRoom: 0x12u,
lfgRoom: 0x13u,
roleplayRoom: 0x14u,
olthoiRoom: 0x15u,
societyRoom: 0x16u,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
RuntimeCommandResult[] stateAndWireCommands =
[
adapter.Selection.SelectObject(
runtime.Generation,
selectedObject),
adapter.Selection.Clear(runtime.Generation),
adapter.Movement.SetIntent(
runtime.Generation,
new MovementInput(Forward: true, Run: true)),
adapter.Movement.ClearIntent(runtime.Generation),
adapter.Movement.Execute(
runtime.Generation,
RuntimeMovementCommand.Stop),
adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Fellowship,
"group")),
adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.General,
"global")),
// CH3 (2026-08-09): Roleplay's room id is populated (0x14u
// above) but RuntimeCharacterOptionsState's fresh default omits
// HearRoleplayChat (matches ACE's own CharacterOptions2.Default)
// — the membership gate must refuse LOCALLY (via AddText, "You
// are not listening to the Roleplay channel!") rather than
// silently sending or silently dropping.
adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Roleplay,
"should be refused")),
adapter.InventoryState.AddShortcut(
runtime.Generation,
new RuntimeShortcutCommand(0, 0x70000001u, 0u)),
adapter.InventoryState.RemoveShortcut(
runtime.Generation,
0),
adapter.Spellbook.AddFavorite(
runtime.Generation,
tabIndex: 0,
position: 0,
spellId: 7u),
adapter.Spellbook.RemoveFavorite(
runtime.Generation,
tabIndex: 0,
spellId: 7u),
adapter.Spellbook.SetFilter(
runtime.Generation,
filters: 3u),
adapter.Spellbook.ForgetSpell(
runtime.Generation,
spellId: 7u),
adapter.Spellbook.SetDesiredComponent(
runtime.Generation,
componentId: 11u,
amount: 3u),
adapter.Spellbook.ClearDesiredComponents(
runtime.Generation),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Attribute,
StatId: 1u,
Cost: 10u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Vital,
StatId: 2u,
Cost: 11u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Skill,
StatId: 3u,
Cost: 12u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.TrainSkill,
StatId: 4u,
Cost: 1u)),
adapter.Character.SetSingleOption(
runtime.Generation,
optionId: 0x26u,
value: true),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Add,
Name: "Friend")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Remove,
CharacterId: 0x50000003u)),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Clear)),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Character,
Add: true,
CharacterId: 0x50000004u,
Name: "Muted")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Account,
Add: true,
Name: "AccountMuted")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Global,
Add: true,
MessageType: 2u)),
];
RuntimeSessionStartResult reconnected =
adapter.Session.Reconnect(runtime.Generation);
RuntimeCommandResult stale = adapter.Chat.Execute(
firstGeneration,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"stale"));
RuntimeCommandResult staleMovement =
adapter.Movement.SetIntent(
firstGeneration,
new MovementInput(Forward: true));
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
reconnected.Status);
Assert.True(chat.Accepted);
Assert.True(portal.Accepted);
Assert.All(
stateAndWireCommands,
result => Assert.Equal(
RuntimeCommandStatus.Accepted,
result.Status));
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
stale.Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
staleMovement.Status);
Assert.False(runtime.MovementOwner.HasCommandInput);
Assert.True(gameActions.Count >= 20);
Assert.Contains(
runtime.CommunicationOwner.Chat.Snapshot(),
entry => entry.Text.Contains(
"not listening to the Roleplay channel",
StringComparison.OrdinalIgnoreCase));
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Chat
&& entry.Text == "hello");
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Portal);
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Combat);
RuntimeTeardownAcknowledgement stopped =
adapter.Session.Stop(runtime.Generation);
Assert.True(stopped.IsComplete);
Assert.False(runtime.Session.IsInWorld);
}
// ── OP1 (Campaign OP, 2026-08-10): the headless local-write-then-send
// seam, SaveOptions, and unknown-id rejection ───────────────────────
[Fact]
public void SetSingleOption_AutoSaveId_WritesLocalBitBeforeTheWireSendFires()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
uint? options2AtSendTime = null;
operations.Sessions[^1].GameActionCapture = _ =>
options2AtSendTime ??= runtime.CharacterOwner.Options.Options2;
// Options2 default (0x00948700) has HearGeneralChat (0x100) ON;
// toggling it OFF exercises the local-write-then-send ordering this
// slice fixed on the headless path (lane B §4.4 / lane C §7.4).
RuntimeCommandResult result = adapter.Character.SetSingleOption(
runtime.Generation,
(uint)CharacterOptionId.ListenToGeneralChat,
false);
Assert.True(result.Accepted);
Assert.NotNull(options2AtSendTime);
Assert.Equal(0u, options2AtSendTime!.Value & 0x00000100u);
Assert.Equal(
0u,
runtime.CharacterOwner.Options.Options2 & 0x00000100u);
runtime.Dispose();
}
[Fact]
public void SetSingleOption_BatchedId_MarksDirtyWithoutSendingAnything()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
// AutoTarget (0x0D) is batched, default ON — flip it off.
RuntimeCommandResult result = adapter.Character.SetSingleOption(
runtime.Generation,
(uint)CharacterOptionId.AutoTarget,
false);
Assert.True(result.Accepted);
Assert.Empty(gameActions);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
runtime.Dispose();
}
[Fact]
public void SetSingleOption_UnknownId_RejectsWithoutSendingAnything()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
// 0x35 == CharacterOptions1Default — the whole-default-mask landmine
// (wire research §5.4.3), never a real option.
RuntimeCommandResult result = adapter.Character.SetSingleOption(
runtime.Generation,
0x35u,
true);
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
Assert.Empty(gameActions);
runtime.Dispose();
}
[Fact]
public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
// MUST-FIX M1 (OP1 review fix, 2026-08-11): seed the server truth
// (as a real session's PlayerDescription would) before the flush —
// otherwise TryFlush now refuses outright (see
// SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty below).
SeedServerOptions(runtime);
adapter.Character.SetSingleOption(
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
Assert.Empty(gameActions);
RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation);
Assert.True(saved.Accepted);
Assert.False(runtime.CharacterOwner.Options.IsDirty);
byte[] blob = Assert.Single(gameActions);
Assert.Equal(
SocialActions.SetCharacterOptionsOpcode,
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
blob.AsSpan(8)));
// S4 (OP1 review fix, blast lens): PrimaryObjectId no longer encodes
// whether the flush actually fired — both hosts report the SAME
// shape (Accepted, objectId 0).
Assert.Equal(0u, saved.ResultObjectId);
// A clean module's second SaveOptions sends nothing more.
RuntimeCommandResult savedAgain =
adapter.Character.SaveOptions(runtime.Generation);
Assert.True(savedAgain.Accepted);
Assert.Equal(0u, savedAgain.ResultObjectId);
Assert.Single(gameActions);
runtime.Dispose();
}
[Fact]
public void SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
// MUST-FIX M1 (OP1 review fix, 2026-08-11): CreateStartedHarness's
// RuntimeCharacterOptionsState starts at CLIENT constructor defaults
// — no PlayerDescription has landed yet (HasServerSeed is false). A
// blob flush here would ship acdream's defaults over the
// character's real server-side options — the wipe class M1 closes.
adapter.Character.SetSingleOption(
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation);
Assert.True(saved.Accepted);
Assert.Empty(gameActions);
// Nothing lost, nothing sent — the pending change is still pending.
Assert.True(runtime.CharacterOwner.Options.IsDirty);
// The server's real PlayerDescription lands. S5: the seed
// supersedes the (unsent) pending change, so dirty a FRESH change
// after the seed to prove the GATE — not the module — was what
// refused above.
SeedServerOptions(runtime);
Assert.False(runtime.CharacterOwner.Options.IsDirty);
adapter.Character.SetSingleOption(
runtime.Generation, (uint)CharacterOptionId.ShowTooltips, false);
RuntimeCommandResult savedAfterSeed =
adapter.Character.SaveOptions(runtime.Generation);
Assert.True(savedAfterSeed.Accepted);
Assert.Single(gameActions);
Assert.False(runtime.CharacterOwner.Options.IsDirty);
runtime.Dispose();
}
// ── MUST-FIX 1 (Campaign OP OP1 review fix, mechanism lens, 2026-08-11):
// end-to-end proof that GameRuntime's real wiring — not just
// LiveSessionController's hook mechanics in isolation
// (LiveSessionControllerTests.cs) — actually auto-flushes the batched
// blob from an ordinary Session.Tick() once the 480 s timer is due.
[Fact]
public void Session_Tick_AutoFlushesTheDirtyBlob_OnceThe480sTimerIsDue()
{
var clock = new ManualTimeProvider();
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness(clock);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
SeedServerOptions(runtime);
adapter.Character.SetSingleOption(
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
// Before the timer: an ordinary tick must not flush.
runtime.Session.Tick();
Assert.Empty(gameActions);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay + TimeSpan.FromSeconds(1));
runtime.Session.Tick();
byte[] blob = Assert.Single(gameActions);
Assert.Equal(
SocialActions.SetCharacterOptionsOpcode,
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
blob.AsSpan(8)));
Assert.False(runtime.CharacterOwner.Options.IsDirty);
runtime.Dispose();
}
[Fact]
public void Stop_AutoFlushesTheDirtyBlob_BeforeTheCharacterLogoffRequest()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var order = new List<string>();
operations.Sessions[^1].GameActionCapture = body =>
{
uint opcode = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
body.AsSpan(8));
if (opcode == SocialActions.SetCharacterOptionsOpcode)
order.Add("options-blob");
};
SeedServerOptions(runtime);
adapter.Character.SetSingleOption(
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
Assert.True(runtime.CharacterOwner.Options.IsDirty);
RuntimeTeardownAcknowledgement stopped =
adapter.Session.Stop(runtime.Generation);
Assert.True(stopped.IsComplete);
Assert.Equal(["options-blob"], order);
runtime.Dispose();
}
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────
private static uint ReadOpcode(byte[] gameAction) =>
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
gameAction.AsSpan(8));
[Fact]
public void Fellowship_Create_SendsTheCreateOpcodeWithNameAndShareXp()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
RuntimeCommandResult result = adapter.Fellowship.Create(
runtime.Generation, "TestFellowship", shareXp: true);
Assert.True(result.Accepted);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipCreateOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_Create_RejectsAnEmptyName_WithoutSendingAnything()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
RuntimeCommandResult result = adapter.Fellowship.Create(
runtime.Generation, string.Empty, shareXp: false);
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
Assert.Empty(gameActions);
runtime.Dispose();
}
[Fact]
public void Fellowship_RecruitAndDismiss_SendTheirOpcodesWithTheTargetGuid()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Recruit(runtime.Generation, 0x50000005u);
adapter.Fellowship.Dismiss(runtime.Generation, 0x50000006u);
Assert.Equal(2, gameActions.Count);
Assert.Equal(SocialActions.FellowshipRecruitOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipDismissOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_NotLeader_SendsOnlyTheQuitOpcode()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000002u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: false);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_LeaderWithoutDisbanding_SendsAssignNewLeaderBeforeQuit()
{
// Retail's Quit-button leader hand-off (lane B §2.5/§3.6).
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: false);
Assert.Equal(2, gameActions.Count);
Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_LeaderDisbanding_SendsOnlyTheQuitOpcode()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: true);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_AssignLeaderSetOpenSetPanelOpen_SendTheirOpcodes()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.AssignLeader(runtime.Generation, 0x50000009u);
adapter.Fellowship.SetOpen(runtime.Generation, isOpen: true);
adapter.Fellowship.SetPanelOpen(runtime.Generation, panelOpen: true);
Assert.Equal(3, gameActions.Count);
Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipChangeOpennessOpcode, ReadOpcode(gameActions[1]));
Assert.Equal(SocialActions.FellowshipUpdateRequestOpcode, ReadOpcode(gameActions[2]));
runtime.Dispose();
}
[Fact]
public void Allegiance_SwearBreakKick_SendTheirOpcodesWithTheTargetGuid()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Allegiance.Swear(runtime.Generation, 0x50000010u);
adapter.Allegiance.Break(runtime.Generation, 0x50000011u);
adapter.Allegiance.Kick(runtime.Generation, 0x50000012u);
Assert.Equal(3, gameActions.Count);
Assert.Equal(AllegianceRequests.SwearOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[1]));
Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[2]));
runtime.Dispose();
}
[Fact]
public void Allegiance_RequestInfoAndSetUpdateSubscription_SendTheirOpcodes()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Allegiance.RequestInfo(runtime.Generation, string.Empty);
adapter.Allegiance.SetUpdateSubscription(runtime.Generation, on: true);
Assert.Equal(2, gameActions.Count);
Assert.Equal(ClientCommandRequests.AllegianceInfoRequestOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(AllegianceRequests.AllegianceUpdateRequestOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_And_Allegiance_Commands_RejectAStaleGeneration()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
RuntimeGenerationToken stale = runtime.Generation;
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Session.Reconnect(runtime.Generation);
RuntimeCommandResult fellowshipResult =
adapter.Fellowship.Create(stale, "Stale", false);
RuntimeCommandResult allegianceResult =
adapter.Allegiance.Swear(stale, 0x50000001u);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, fellowshipResult.Status);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, allegianceResult.Status);
Assert.Empty(gameActions);
runtime.Dispose();
}
private static void SeedFellowship(
GameRuntime runtime,
uint leader,
uint self,
uint others)
{
GameEvents.FellowMember Member(uint guid, string name) => new(
guid, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, name);
runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
[Member(leader, "Leader"), Member(self, "Self"), Member(others, "Other")],
"The Fellows",
leader,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []));
}
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan elapsed) => _now += elapsed;
}
private static void SeedServerOptions(GameRuntime runtime) =>
runtime.CharacterOwner.Options.Replace(
runtime.CharacterOwner.Options.Options1,
runtime.CharacterOwner.Options.Options2);
private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations)
CreateStartedHarness(TimeProvider? timeProvider = null)
{
var operations = new FixtureSessionOperations();
var gameplay = new FixtureGameplayOperations();
var runtime = new GameRuntime(new GameRuntimeDependencies(
gameplay,
gameplay,
gameplay,
gameplay,
TimeProvider: timeProvider,
SessionOperations: operations));
gameplay.Bind(runtime);
var resetHost = new FixtureResetHost();
DirectGameRuntimeCommandAdapter? adapter = null;
LiveSessionConnectOptions options = new(
true,
"127.0.0.1",
9000,
"account",
"password");
var live = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
_ => new FixtureEventRoute(),
session => adapter!.CreateRoute(session)),
generation => runtime.ResetGeneration(
generation,
resetHost),
new LiveSessionSelectionBindings(
id => runtime.PlayerIdentity.ServerGuid = id,
_ => { },
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
_ => { },
_ => { },
runtime.ActionOwner.Combat.Clear),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
_ = adapter.Session.Start(runtime.Generation);
return (runtime, adapter, operations);
}
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint)
{
var session = new WorldSession(
endpoint,
new FixtureTransport());
Sessions.Add(session);
return session;
}
public void Connect(
WorldSession session,
string user,
string password)
{
}
public CharacterList.Parsed GetCharacters(WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Direct",
0u),
],
[],
11,
"account",
true,
true);
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session) =>
session.Dispose();
}
private sealed class FixtureEventRoute : ILiveSessionEventRouting
{
public void Attach()
{
}
public void Dispose()
{
}
}
private sealed class FixtureResetHost : IRuntimeGenerationResetHost
{
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
}
public void DrainEntityProjectionBoundary()
{
}
public void CompleteEntityProjectionRetirement()
{
}
}
private sealed class FixtureTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
new OperationCanceledException(cancellationToken));
public void Dispose()
{
}
}
private sealed class FixtureGameplayOperations
: IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
private GameRuntime? _runtime;
public void Bind(GameRuntime runtime) => _runtime = runtime;
public bool CanStartAttack() => false;
public void PrepareAttackRequest()
{
}
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack()
{
}
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool AutoRepeatAttack => false;
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest()
{
}
public void SendChangeCombatMode(CombatMode mode)
{
}
public uint LocalPlayerId =>
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely()
{
}
public void SendUntargeted(uint spellId)
{
}
public void SendTargeted(uint targetId, uint spellId)
{
}
public void DisplayMessage(string message)
{
}
public void IncrementBusy()
{
}
}
}