Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.
MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.
MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.
SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).
SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.
SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.
SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.
SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.
SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).
SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.
SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.
Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.
Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
from inside a flush callback can still be erased by the trailing
"_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
(same outcome whether the callback runs inside or outside the lock),
not reachable from any current caller, not a one-liner to close
correctly (needs a per-dirty-period generation token). Left as documented
in the review; worth closing before the Options panel ever flushes from
inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
touching files this round doesn't otherwise edit (SocialActions.cs,
CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
the "one-liner in a file already being edited" instruction.
Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).
Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
886 lines
32 KiB
C#
886 lines
32 KiB
C#
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Runtime;
|
|
|
|
/// <summary>
|
|
/// Generation-gated synchronous command projection onto the same current
|
|
/// owners used by graphical input. It introduces no queue or frame boundary.
|
|
/// </summary>
|
|
internal sealed class CurrentGameRuntimeCommandAdapter
|
|
: IRuntimeSessionCommands,
|
|
IRuntimeSelectionCommands,
|
|
IRuntimeCombatCommands,
|
|
IRuntimeMagicCommands,
|
|
IRuntimeMovementCommands,
|
|
IRuntimeChatCommands,
|
|
IRuntimePortalCommands,
|
|
IRuntimeInventoryStateCommands,
|
|
IRuntimeSpellbookCommands,
|
|
IRuntimeCharacterCommands,
|
|
IRuntimeSocialCommands
|
|
{
|
|
private readonly LiveSessionController _session;
|
|
private readonly LiveSessionHost _sessionHost;
|
|
private readonly ICommandBus _commands;
|
|
private readonly IGameRuntimeView _view;
|
|
private readonly RuntimeInventoryState _inventory;
|
|
private readonly RuntimeCharacterState _character;
|
|
private readonly RuntimeActionState _actions;
|
|
private readonly RuntimeLocalPlayerMovementState _movement;
|
|
private readonly SelectionInteractionController _selection;
|
|
private readonly IGameRuntimeEventSink _events;
|
|
|
|
public CurrentGameRuntimeCommandAdapter(
|
|
LiveSessionController session,
|
|
LiveSessionHost sessionHost,
|
|
ICommandBus commands,
|
|
IGameRuntimeView view,
|
|
RuntimeInventoryState inventory,
|
|
RuntimeCharacterState character,
|
|
RuntimeActionState actions,
|
|
RuntimeLocalPlayerMovementState movement,
|
|
SelectionInteractionController selection,
|
|
IGameRuntimeEventSink events)
|
|
{
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
|
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
|
_view = view ?? throw new ArgumentNullException(nameof(view));
|
|
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
|
|
_character = character ?? throw new ArgumentNullException(nameof(character));
|
|
_actions = actions
|
|
?? throw new ArgumentNullException(nameof(actions));
|
|
_movement = movement
|
|
?? throw new ArgumentNullException(nameof(movement));
|
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
|
}
|
|
|
|
public RuntimeSessionStartResult Start(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeLifecycleState previous = _view.Lifecycle.State;
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return RejectedStart(gate);
|
|
|
|
RuntimeSessionStartResult result =
|
|
_sessionHost.Start(expectedGeneration);
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Session,
|
|
operation: 0,
|
|
ToCommandStatus(result.Status),
|
|
result.CharacterId,
|
|
result.CharacterName);
|
|
_events.EmitLifecycle(previous, _view.Lifecycle.State);
|
|
return result;
|
|
}
|
|
|
|
public RuntimeSessionStartResult Reconnect(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeLifecycleState previous = _view.Lifecycle.State;
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return RejectedStart(gate);
|
|
|
|
RuntimeSessionStartResult result =
|
|
_sessionHost.Reconnect(expectedGeneration);
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Session,
|
|
operation: 1,
|
|
ToCommandStatus(result.Status),
|
|
result.CharacterId,
|
|
result.CharacterName);
|
|
_events.EmitLifecycle(previous, _view.Lifecycle.State);
|
|
return result;
|
|
}
|
|
|
|
public RuntimeTeardownAcknowledgement Stop(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeLifecycleState previous = _view.Lifecycle.State;
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
{
|
|
return new RuntimeTeardownAcknowledgement(
|
|
expectedGeneration,
|
|
_view.Generation,
|
|
gate,
|
|
RuntimeTeardownStage.None);
|
|
}
|
|
|
|
RuntimeTeardownAcknowledgement acknowledgement =
|
|
_sessionHost.Stop(expectedGeneration);
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Session,
|
|
operation: 2,
|
|
acknowledgement.Status,
|
|
text: acknowledgement.Error?.GetType().Name ?? string.Empty);
|
|
_events.EmitLifecycle(previous, _view.Lifecycle.State);
|
|
return acknowledgement;
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
RuntimeSelectionCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
InputAction action = command switch
|
|
{
|
|
RuntimeSelectionCommand.SelectClosestHostile =>
|
|
InputAction.SelectionClosestMonster,
|
|
RuntimeSelectionCommand.SelectPrevious =>
|
|
InputAction.SelectionPreviousSelection,
|
|
RuntimeSelectionCommand.ExamineSelected =>
|
|
InputAction.SelectionExamine,
|
|
RuntimeSelectionCommand.UseSelected =>
|
|
InputAction.UseSelected,
|
|
RuntimeSelectionCommand.PickUpSelected =>
|
|
InputAction.SelectionPickUp,
|
|
_ => InputAction.None,
|
|
};
|
|
RuntimeCommandStatus status = action != InputAction.None
|
|
&& _selection.HandleInputAction(action)
|
|
? RuntimeCommandStatus.Accepted
|
|
: RuntimeCommandStatus.Unsupported;
|
|
uint selected = _actions.Selection.SelectedObjectId ?? 0u;
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Selection,
|
|
(int)command,
|
|
status,
|
|
selected);
|
|
return Result(status, selected);
|
|
}
|
|
|
|
public RuntimeCommandResult SelectObject(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint objectId)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
RuntimeCommandStatus status =
|
|
objectId != 0u
|
|
&& _inventory.Objects.Get(objectId) is not null
|
|
? RuntimeCommandStatus.Accepted
|
|
: RuntimeCommandStatus.Rejected;
|
|
if (status == RuntimeCommandStatus.Accepted)
|
|
{
|
|
_actions.Selection.Select(
|
|
objectId,
|
|
SelectionChangeSource.Plugin);
|
|
}
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Selection,
|
|
operation: 0x100,
|
|
status,
|
|
objectId);
|
|
return Result(status, objectId);
|
|
}
|
|
|
|
public RuntimeCommandResult Clear(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
_actions.Selection.Clear(SelectionChangeSource.Plugin);
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Selection,
|
|
operation: 0x101,
|
|
RuntimeCommandStatus.Accepted);
|
|
return Result(RuntimeCommandStatus.Accepted);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
RuntimeCombatCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
RuntimeCommandStatus status;
|
|
if (command == RuntimeCombatCommand.ToggleMode)
|
|
{
|
|
RuntimeCombatModeRequestResult result =
|
|
_actions.CombatMode.Toggle();
|
|
status = result.Status switch
|
|
{
|
|
RuntimeCombatModeRequestStatus.Sent =>
|
|
RuntimeCommandStatus.Accepted,
|
|
RuntimeCombatModeRequestStatus.Inactive =>
|
|
RuntimeCommandStatus.Inactive,
|
|
_ => RuntimeCommandStatus.Rejected,
|
|
};
|
|
// #260 probe: the toggle's own Inactive exit comes from a
|
|
// DIFFERENT IsInWorld source than Validate's (the operations
|
|
// slot, which reads false when unbound). Logging the result
|
|
// here separates "gate passed, toggle refused" from "gate
|
|
// rejected" — the two look identical to the player.
|
|
if (AcDream.Core.Net.NetDiagnostics.ProbeNet)
|
|
{
|
|
Console.WriteLine(
|
|
$"[cmd-gate] combat toggle result={result.Status}"
|
|
+ $" mode={result.Mode}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
status = RuntimeCommandStatus.Unsupported;
|
|
}
|
|
|
|
_events.EmitCommand(RuntimeCommandDomain.Combat, (int)command, status);
|
|
return Result(status);
|
|
}
|
|
|
|
public RuntimeCommandResult ExecuteAttack(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeCombatAttackInput command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
RuntimeCommandStatus status = _actions.CombatAttack.HandleCommand(
|
|
command)
|
|
? RuntimeCommandStatus.Accepted
|
|
: RuntimeCommandStatus.Unsupported;
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Combat,
|
|
operation: 0x100 + (int)command.Command,
|
|
status);
|
|
return Result(status);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeMagicCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
CastRequestResult cast = _actions.SpellCast.Cast(command.SpellId);
|
|
RuntimeCommandStatus status = cast == CastRequestResult.Sent
|
|
? RuntimeCommandStatus.Accepted
|
|
: RuntimeCommandStatus.Rejected;
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Magic,
|
|
operation: (int)cast,
|
|
status,
|
|
command.SpellId);
|
|
return Result(status, command.SpellId);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
RuntimeMovementCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
RuntimeCommandStatus status = _movement.Execute(command)
|
|
? RuntimeCommandStatus.Accepted
|
|
: RuntimeCommandStatus.Unsupported;
|
|
|
|
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
|
|
return Result(status);
|
|
}
|
|
|
|
public RuntimeCommandResult SetIntent(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in MovementInput input)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
_movement.SetCommandInput(input);
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Movement,
|
|
operation: 0x100,
|
|
RuntimeCommandStatus.Accepted);
|
|
return Result(RuntimeCommandStatus.Accepted);
|
|
}
|
|
|
|
public RuntimeCommandResult ClearIntent(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(
|
|
expectedGeneration,
|
|
requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
_movement.ClearCommandInput();
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Movement,
|
|
operation: 0x101,
|
|
RuntimeCommandStatus.Accepted);
|
|
return Result(RuntimeCommandStatus.Accepted);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeChatCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
if (!TryMap(command.Channel, out ChatChannelKind channel))
|
|
{
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Chat,
|
|
(int)command.Channel,
|
|
RuntimeCommandStatus.Unsupported);
|
|
return Result(RuntimeCommandStatus.Unsupported);
|
|
}
|
|
|
|
_commands.Publish(new SendChatCmd(
|
|
channel,
|
|
command.TargetName,
|
|
command.Text));
|
|
_events.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, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
|
|
ClientCommandId? commandId = command switch
|
|
{
|
|
RuntimePortalCommand.RecallLifestone =>
|
|
ClientCommandId.LifestoneRecall,
|
|
RuntimePortalCommand.RecallMarketplace =>
|
|
ClientCommandId.MarketplaceRecall,
|
|
RuntimePortalCommand.RecallHouse =>
|
|
ClientCommandId.HouseRecall,
|
|
RuntimePortalCommand.RecallMansion =>
|
|
ClientCommandId.MansionRecall,
|
|
_ => null,
|
|
};
|
|
if (commandId is null)
|
|
{
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Portal,
|
|
(int)command,
|
|
RuntimeCommandStatus.Unsupported);
|
|
return Result(RuntimeCommandStatus.Unsupported);
|
|
}
|
|
|
|
_commands.Publish(new ExecuteClientCommandCmd(
|
|
commandId.Value,
|
|
string.Empty));
|
|
_events.EmitCommand(
|
|
RuntimeCommandDomain.Portal,
|
|
(int)command,
|
|
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);
|
|
var entry = new ShortcutEntry(
|
|
command.Index,
|
|
command.ObjectId,
|
|
command.SpellId);
|
|
if (!_inventory.TryAddShortcut(
|
|
entry,
|
|
() => _commands.Publish(new AddShortcutRuntimeCmd(entry))))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.InventoryState,
|
|
operation: 0,
|
|
RuntimeCommandStatus.Rejected,
|
|
command.ObjectId);
|
|
}
|
|
|
|
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 (!_inventory.TryRemoveShortcut(
|
|
index,
|
|
() => _commands.Publish(
|
|
new RemoveShortcutRuntimeCmd((uint)index))))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.InventoryState,
|
|
operation: 1,
|
|
RuntimeCommandStatus.Rejected);
|
|
}
|
|
|
|
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 (!_character.TryAddFavorite(
|
|
tabIndex,
|
|
position,
|
|
spellId,
|
|
() => _commands.Publish(new AddFavoriteRuntimeCmd(
|
|
spellId,
|
|
position,
|
|
tabIndex))))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Spellbook,
|
|
operation: 0,
|
|
RuntimeCommandStatus.Rejected,
|
|
spellId);
|
|
}
|
|
|
|
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 (!_character.TryRemoveFavorite(
|
|
tabIndex,
|
|
spellId,
|
|
() => _commands.Publish(
|
|
new RemoveFavoriteRuntimeCmd(spellId, tabIndex))))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Spellbook,
|
|
operation: 1,
|
|
RuntimeCommandStatus.Rejected,
|
|
spellId);
|
|
}
|
|
|
|
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);
|
|
_character.SetSpellbookFilter(
|
|
filters,
|
|
() => _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 (!_character.TrySetDesiredComponent(
|
|
componentId,
|
|
amount,
|
|
() => _commands.Publish(new SetDesiredComponentRuntimeCmd(
|
|
componentId,
|
|
amount))))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Spellbook,
|
|
operation: 4,
|
|
RuntimeCommandStatus.Rejected,
|
|
componentId);
|
|
}
|
|
|
|
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);
|
|
_character.ClearDesiredComponents(
|
|
() => _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 SetSingleOption(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint optionId,
|
|
bool value)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
// OP1 (Campaign OP, 2026-08-10): reject an id outside
|
|
// CharacterOptionTable at THIS seam too — the router's
|
|
// SetSingleCharacterOptionRuntimeCmd handler ultimately funnels into
|
|
// RuntimeCharacterOptionsState.TrySetOption (the SAME shared write-
|
|
// then-send/dirty policy the direct host uses), which would also
|
|
// reject it, but LiveCommandBus.Publish has no return value for that
|
|
// rejection to travel back on.
|
|
if (!CharacterOptionTable.TryGet(optionId, out _))
|
|
{
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Character,
|
|
operation: 4,
|
|
RuntimeCommandStatus.Rejected);
|
|
}
|
|
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Character,
|
|
operation: 4,
|
|
RuntimeCommandStatus.Accepted);
|
|
}
|
|
|
|
public RuntimeCommandResult SaveOptions(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
_commands.Publish(new SaveCharacterOptionsRuntimeCmd());
|
|
// S4 (OP1 review fix, blast lens, 2026-08-11): objectId 0 — the bus
|
|
// has no return channel to report whether the deferred flush
|
|
// actually fired, so this host reports the SAME shape
|
|
// DirectGameRuntimeCommandAdapter.SaveOptions now does (Accepted,
|
|
// objectId 0) rather than a host-specific encoding.
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Character,
|
|
operation: 5,
|
|
RuntimeCommandStatus.Accepted);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeFriendCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
|
|
switch (command.Kind)
|
|
{
|
|
case RuntimeFriendCommandKind.Add
|
|
when !string.IsNullOrWhiteSpace(command.Name):
|
|
_commands.Publish(new AddFriendRuntimeCmd(command.Name));
|
|
break;
|
|
case RuntimeFriendCommandKind.Remove
|
|
when command.CharacterId != 0u:
|
|
_commands.Publish(new RemoveFriendRuntimeCmd(
|
|
command.CharacterId));
|
|
break;
|
|
case RuntimeFriendCommandKind.Clear:
|
|
_commands.Publish(new ClearFriendsRuntimeCmd());
|
|
break;
|
|
case RuntimeFriendCommandKind.RequestLegacyList:
|
|
_commands.Publish(new RequestLegacyFriendsRuntimeCmd());
|
|
break;
|
|
default:
|
|
status = RuntimeCommandStatus.Rejected;
|
|
break;
|
|
}
|
|
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Social,
|
|
(int)command.Kind,
|
|
status,
|
|
command.CharacterId,
|
|
command.Name);
|
|
}
|
|
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeSquelchCommand command)
|
|
{
|
|
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
|
if (gate != RuntimeCommandStatus.Accepted)
|
|
return Result(gate);
|
|
RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
|
|
switch (command.Scope)
|
|
{
|
|
case RuntimeSquelchScope.Character
|
|
when command.CharacterId != 0u
|
|
&& !string.IsNullOrWhiteSpace(command.Name):
|
|
_commands.Publish(new ModifyCharacterSquelchRuntimeCmd(
|
|
command.Add,
|
|
command.CharacterId,
|
|
command.Name,
|
|
command.MessageType));
|
|
break;
|
|
case RuntimeSquelchScope.Account
|
|
when !string.IsNullOrWhiteSpace(command.Name):
|
|
_commands.Publish(new ModifyAccountSquelchRuntimeCmd(
|
|
command.Add,
|
|
command.Name));
|
|
break;
|
|
case RuntimeSquelchScope.Global:
|
|
_commands.Publish(new ModifyGlobalSquelchRuntimeCmd(
|
|
command.Add,
|
|
command.MessageType));
|
|
break;
|
|
default:
|
|
status = RuntimeCommandStatus.Rejected;
|
|
break;
|
|
}
|
|
|
|
return EmitResult(
|
|
RuntimeCommandDomain.Social,
|
|
operation: 4 + (int)command.Scope,
|
|
status,
|
|
command.CharacterId,
|
|
command.Name);
|
|
}
|
|
|
|
private RuntimeCommandStatus Validate(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
bool requireWorld)
|
|
{
|
|
RuntimeCommandStatus status;
|
|
if (_view.Lifecycle.State == RuntimeLifecycleState.Disposed)
|
|
status = RuntimeCommandStatus.Inactive;
|
|
else if (expectedGeneration != _view.Generation)
|
|
status = RuntimeCommandStatus.StaleGeneration;
|
|
else if (requireWorld && !_session.IsInWorld)
|
|
status = RuntimeCommandStatus.Inactive;
|
|
else
|
|
status = RuntimeCommandStatus.Accepted;
|
|
|
|
// #260 probe: a rejected generation-gated command is otherwise
|
|
// COMPLETELY silent (no log, no event) — which is exactly how the
|
|
// portal-network wedge hid. Log every rejection with the full gate
|
|
// state so the failing predicate names itself.
|
|
if (status != RuntimeCommandStatus.Accepted
|
|
&& AcDream.Core.Net.NetDiagnostics.ProbeNet)
|
|
{
|
|
Console.WriteLine(
|
|
$"[cmd-gate] REJECT status={status}"
|
|
+ $" expected={expectedGeneration}"
|
|
+ $" view={_view.Generation}"
|
|
+ $" lifecycle={_view.Lifecycle.State}"
|
|
+ $" inWorld={_session.IsInWorld}");
|
|
}
|
|
return status;
|
|
}
|
|
|
|
private RuntimeCommandResult Result(
|
|
RuntimeCommandStatus status,
|
|
uint objectId = 0u) =>
|
|
new(status, _view.Generation, objectId);
|
|
|
|
private RuntimeCommandResult EmitResult(
|
|
RuntimeCommandDomain domain,
|
|
int operation,
|
|
RuntimeCommandStatus status,
|
|
uint objectId = 0u,
|
|
string? text = null)
|
|
{
|
|
_events.EmitCommand(domain, operation, status, objectId, text);
|
|
return Result(status, objectId);
|
|
}
|
|
|
|
private RuntimeSessionStartResult RejectedStart(RuntimeCommandStatus status) =>
|
|
new(
|
|
status == RuntimeCommandStatus.StaleGeneration
|
|
? RuntimeSessionStartStatus.StaleGeneration
|
|
: RuntimeSessionStartStatus.Inactive,
|
|
_view.Generation);
|
|
|
|
private static RuntimeCommandStatus ToCommandStatus(
|
|
RuntimeSessionStartStatus status) =>
|
|
status switch
|
|
{
|
|
RuntimeSessionStartStatus.Failed => RuntimeCommandStatus.Rejected,
|
|
RuntimeSessionStartStatus.Inactive => RuntimeCommandStatus.Inactive,
|
|
RuntimeSessionStartStatus.StaleGeneration =>
|
|
RuntimeCommandStatus.StaleGeneration,
|
|
_ => RuntimeCommandStatus.Accepted,
|
|
};
|
|
|
|
private static bool TryMap(
|
|
RuntimeChatChannel channel,
|
|
out ChatChannelKind result)
|
|
{
|
|
result = channel switch
|
|
{
|
|
RuntimeChatChannel.Say => ChatChannelKind.Say,
|
|
RuntimeChatChannel.Tell => ChatChannelKind.Tell,
|
|
RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship,
|
|
RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance,
|
|
RuntimeChatChannel.AllegianceBroadcast => ChatChannelKind.AllegianceBroadcast,
|
|
RuntimeChatChannel.Vassals => ChatChannelKind.Vassals,
|
|
RuntimeChatChannel.Patron => ChatChannelKind.Patron,
|
|
RuntimeChatChannel.Monarch => ChatChannelKind.Monarch,
|
|
RuntimeChatChannel.CoVassals => ChatChannelKind.CoVassals,
|
|
RuntimeChatChannel.General => ChatChannelKind.General,
|
|
RuntimeChatChannel.Trade => ChatChannelKind.Trade,
|
|
RuntimeChatChannel.LookingForGroup => ChatChannelKind.Lfg,
|
|
RuntimeChatChannel.Roleplay => ChatChannelKind.Roleplay,
|
|
RuntimeChatChannel.Society => ChatChannelKind.Society,
|
|
RuntimeChatChannel.Olthoi => ChatChannelKind.Olthoi,
|
|
_ => ChatChannelKind.Unknown,
|
|
};
|
|
return result != ChatChannelKind.Unknown;
|
|
}
|
|
}
|