fix: complete retail parity stability pass
This commit is contained in:
parent
d3df4cb20a
commit
f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions
|
|
@ -18,6 +18,10 @@ public enum ClientCommandId
|
|||
QueryAge,
|
||||
QueryBirth,
|
||||
ToggleFrameRate,
|
||||
/// <summary>@day — toggle retail's persistent noon landscape lighting.</summary>
|
||||
TogglePersistentDaylight,
|
||||
/// <summary>@render — modify retail's landscape radius or field of view.</summary>
|
||||
RenderOption,
|
||||
ToggleUiLock,
|
||||
ShowVersion,
|
||||
ShowLocation,
|
||||
|
|
@ -84,18 +88,52 @@ public enum ClientCommandId
|
|||
OffChannel,
|
||||
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
|
||||
AllegianceHometown,
|
||||
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
|
||||
/// <summary>"@allegiance info <name>" — request allegiance member info.</summary>
|
||||
AllegianceInfo,
|
||||
/// <summary>"@allegiance boot [-account] <name>".</summary>
|
||||
AllegianceBoot,
|
||||
/// <summary>"@allegiance ban ..." administration dispatcher.</summary>
|
||||
AllegianceBan,
|
||||
/// <summary>"@allegiance chat ..." administration dispatcher.</summary>
|
||||
AllegianceChat,
|
||||
/// <summary>"@allegiance broadcast <text>".</summary>
|
||||
AllegianceBroadcast,
|
||||
/// <summary>"@allegiance officer ..." administration dispatcher.</summary>
|
||||
AllegianceOfficer,
|
||||
/// <summary>"@allegiance title ..." officer-title dispatcher.</summary>
|
||||
AllegianceOfficerTitle,
|
||||
/// <summary>"@allegiance name ..." dispatcher.</summary>
|
||||
AllegianceName,
|
||||
/// <summary>"@allegiance lock ..." dispatcher.</summary>
|
||||
AllegianceLock,
|
||||
/// <summary>"@allegiance house ..." allegiance-house dispatcher.</summary>
|
||||
AllegianceHouse,
|
||||
/// <summary>"@allegiance motd ..." and standalone "@motd ...".</summary>
|
||||
AllegianceMotd,
|
||||
/// <summary>"@house abandon" — abandon the character's house.</summary>
|
||||
HouseAbandon,
|
||||
/// <summary>"@house open|close".</summary>
|
||||
HouseOpenStatus,
|
||||
/// <summary>"@house storage ...".</summary>
|
||||
HouseStorage,
|
||||
/// <summary>"@house remove|boot ...".</summary>
|
||||
HouseBoot,
|
||||
/// <summary>"@house boot_all|remove_all".</summary>
|
||||
HouseBootAll,
|
||||
/// <summary>"@house guest ...".</summary>
|
||||
HouseGuests,
|
||||
/// <summary>"@house hooks on|off".</summary>
|
||||
HouseHooks,
|
||||
/// <summary>
|
||||
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
|
||||
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
|
||||
/// dispatched — <see cref="RetailClientCommandCatalog.Match.HasValidArguments"/>
|
||||
/// is always
|
||||
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
|
||||
/// "Please see @help Allegiance..." refusal and never publishes an
|
||||
/// <c>ExecuteClientCommandCmd</c>.
|
||||
/// A genuinely unrecognized or incomplete <c>@allegiance</c> subcommand.
|
||||
/// The catalog claims it locally and emits retail's Allegiance help
|
||||
/// refusal rather than allowing channel fallback or server passthrough.
|
||||
/// </summary>
|
||||
AllegianceUnrecognizedSubcommand,
|
||||
|
||||
/// <summary>
|
||||
/// An unrecognized or incomplete <c>@house</c> subcommand. Like retail,
|
||||
/// this is claimed locally and prints the House help refusal.
|
||||
/// </summary>
|
||||
HouseUnrecognizedSubcommand,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,597 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent port of retail's nested allegiance and house
|
||||
/// command handlers. Both graphical and headless hosts use this one parser so
|
||||
/// their argument grammar, local refusals, and wire effects cannot drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ported from <c>ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0</c>,
|
||||
/// <c>DoHouse @ 0x00580860</c>, and their named helper functions in the
|
||||
/// September 2013 retail decompile. The action bindings name ACE's matching
|
||||
/// GameAction readers; no raw opcode is exposed at this layer.
|
||||
/// </remarks>
|
||||
public sealed class RetailAdministrationCommandDispatcher
|
||||
{
|
||||
public sealed record FeedbackBindings(
|
||||
Action<string> ShowSystemMessage,
|
||||
Action<string> ShowClientLocalMessage,
|
||||
Action<uint, bool> SetSingleCharacterOption,
|
||||
Action<string> RequestAllegianceInfo);
|
||||
|
||||
public sealed record ActionBindings(
|
||||
Action<string, bool> BreakAllegianceBoot,
|
||||
Action<string, string> AllegianceChatBoot,
|
||||
Action<string, bool> AllegianceChatGag,
|
||||
Action<string> AllegianceBroadcast,
|
||||
Action ListAllegianceBans,
|
||||
Action<string> AddAllegianceBan,
|
||||
Action<string> RemoveAllegianceBan,
|
||||
Action ListAllegianceOfficers,
|
||||
Action ClearAllegianceOfficers,
|
||||
Action<string, uint> SetAllegianceOfficer,
|
||||
Action<string> RemoveAllegianceOfficer,
|
||||
Action ListAllegianceOfficerTitles,
|
||||
Action ClearAllegianceOfficerTitles,
|
||||
Action<uint, string> SetAllegianceOfficerTitle,
|
||||
Action QueryAllegianceName,
|
||||
Action<string> SetAllegianceName,
|
||||
Action ClearAllegianceName,
|
||||
Action<uint> AllegianceLockAction,
|
||||
Action<string> SetAllegianceApprovedVassal,
|
||||
Action<uint> AllegianceHouseAction,
|
||||
Action QueryMotd,
|
||||
Action<string> SetMotd,
|
||||
Action ClearMotd,
|
||||
Action<bool> SetOpenHouseStatus,
|
||||
Action<string> AddPermanentGuest,
|
||||
Action<string> RemovePermanentGuest,
|
||||
Action RemoveAllPermanentGuests,
|
||||
Action<string, bool> ChangeStoragePermission,
|
||||
Action AddAllStoragePermission,
|
||||
Action RemoveAllStoragePermission,
|
||||
Action RequestFullGuestList,
|
||||
Action<string> BootSpecificHouseGuest,
|
||||
Action BootEveryone,
|
||||
Action<bool> SetHooksVisibility,
|
||||
Action<bool> ModifyAllegianceGuestPermission,
|
||||
Action<bool> ModifyAllegianceStoragePermission);
|
||||
|
||||
private readonly FeedbackBindings _feedback;
|
||||
private readonly ActionBindings _actions;
|
||||
|
||||
public RetailAdministrationCommandDispatcher(
|
||||
FeedbackBindings feedback,
|
||||
ActionBindings actions)
|
||||
{
|
||||
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
|
||||
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one typed management command. Returns false only when the id
|
||||
/// is outside this dispatcher's family.
|
||||
/// </summary>
|
||||
public bool TryExecute(ClientCommandId command, string arguments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
switch (command)
|
||||
{
|
||||
case ClientCommandId.AllegianceInfo:
|
||||
ExecuteAllegianceInfo(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceBoot:
|
||||
ExecuteAllegianceBoot(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceBan:
|
||||
ExecuteAllegianceBan(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceChat:
|
||||
ExecuteAllegianceChat(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceBroadcast:
|
||||
ExecuteAllegianceBroadcast(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceOfficer:
|
||||
ExecuteAllegianceOfficer(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceOfficerTitle:
|
||||
ExecuteAllegianceOfficerTitle(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceName:
|
||||
ExecuteAllegianceName(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceLock:
|
||||
ExecuteAllegianceLock(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceHouse:
|
||||
ExecuteAllegianceHouse(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceMotd:
|
||||
ExecuteAllegianceMotd(arguments);
|
||||
return true;
|
||||
case ClientCommandId.AllegianceUnrecognizedSubcommand:
|
||||
ShowAllegianceHelpRefusal();
|
||||
return true;
|
||||
case ClientCommandId.HouseOpenStatus:
|
||||
_actions.SetOpenHouseStatus(
|
||||
arguments.Equals("open", StringComparison.OrdinalIgnoreCase));
|
||||
return true;
|
||||
case ClientCommandId.HouseStorage:
|
||||
ExecuteHouseStorage(arguments);
|
||||
return true;
|
||||
case ClientCommandId.HouseBoot:
|
||||
ExecuteHouseBoot(arguments);
|
||||
return true;
|
||||
case ClientCommandId.HouseBootAll:
|
||||
_actions.BootEveryone();
|
||||
return true;
|
||||
case ClientCommandId.HouseGuests:
|
||||
ExecuteHouseGuests(arguments);
|
||||
return true;
|
||||
case ClientCommandId.HouseHooks:
|
||||
ExecuteHouseHooks(arguments);
|
||||
return true;
|
||||
case ClientCommandId.HouseUnrecognizedSubcommand:
|
||||
ShowHouseHelpRefusal();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceInfo(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
ShowClientLocal("Please specify an actual name.");
|
||||
else
|
||||
_feedback.RequestAllegianceInfo(name);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceBoot(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify an actual name.");
|
||||
return;
|
||||
}
|
||||
|
||||
int accountFlag = name.IndexOf("-account", StringComparison.OrdinalIgnoreCase);
|
||||
bool accountBoot = accountFlag >= 0;
|
||||
if (accountBoot)
|
||||
name = name.Remove(accountFlag, "-account".Length).Trim();
|
||||
|
||||
_feedback.ShowSystemMessage(
|
||||
$"Attempting to boot {name}{(accountBoot ? " (Account)" : string.Empty)}...");
|
||||
_actions.BreakAllegianceBoot(name, accountBoot);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceBan(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_actions.ListAllegianceBans();
|
||||
return;
|
||||
}
|
||||
|
||||
string name = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify an actual name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.AddAllegianceBan(name);
|
||||
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.RemoveAllegianceBan(name);
|
||||
else
|
||||
ShowAllegianceHelpRefusal();
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceChat(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Equals("on", StringComparison.OrdinalIgnoreCase)
|
||||
|| operation.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_feedback.SetSingleCharacterOption(
|
||||
(uint)CharacterOptionId.ListenToAllegianceChat,
|
||||
operation.Equals("on", StringComparison.OrdinalIgnoreCase));
|
||||
return;
|
||||
}
|
||||
|
||||
string remainder = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (operation.Equals("kick", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int comma = remainder.IndexOf(',');
|
||||
string name = comma < 0 ? remainder : remainder[..comma].Trim();
|
||||
string reason = comma < 0
|
||||
? "No reason given."
|
||||
: remainder[(comma + 1)..].Trim();
|
||||
_actions.AllegianceChatBoot(name, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
bool gag = operation.Equals("gag", StringComparison.OrdinalIgnoreCase);
|
||||
bool ungag = operation.Equals("ungag", StringComparison.OrdinalIgnoreCase);
|
||||
if (!gag && !ungag)
|
||||
{
|
||||
ShowAllegianceHelpRefusal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainder.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify an actual name.");
|
||||
return;
|
||||
}
|
||||
|
||||
_actions.AllegianceChatGag(remainder, gag);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceBroadcast(string arguments)
|
||||
{
|
||||
string message = arguments.Trim();
|
||||
if (message.Length == 0)
|
||||
ShowAllegianceHelpRefusal();
|
||||
else
|
||||
_actions.AllegianceBroadcast(message);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceOfficer(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0
|
||||
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_actions.ListAllegianceOfficers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_actions.ClearAllegianceOfficers();
|
||||
return;
|
||||
}
|
||||
|
||||
string remainder = RemainderAfterFirstArgument(arguments);
|
||||
if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string name = remainder.Trim();
|
||||
if (name.Length == 0)
|
||||
ShowClientLocal("Please specify the name of an allegiance member.");
|
||||
else
|
||||
_actions.RemoveAllegianceOfficer(name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|
||||
&& !operation.Equals("set", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ShowAllegianceHelpRefusal();
|
||||
return;
|
||||
}
|
||||
|
||||
string levelText = FirstArgument(remainder);
|
||||
int level = RetailStrtolBaseZero(levelText);
|
||||
if (level is < 1 or > 3)
|
||||
{
|
||||
ShowClientLocal(
|
||||
"Please specify a valid officer level as a number between 1 and 3. "
|
||||
+ "Check the game help files for more information on officer levels.");
|
||||
return;
|
||||
}
|
||||
|
||||
string officerName = RemainderAfterFirstArgument(remainder).Trim();
|
||||
if (officerName.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify the name of an allegiance member.");
|
||||
return;
|
||||
}
|
||||
|
||||
_actions.SetAllegianceOfficer(officerName, (uint)level);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceOfficerTitle(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0
|
||||
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_actions.ListAllegianceOfficerTitles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_actions.ClearAllegianceOfficerTitles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operation.Equals("set", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ShowAllegianceHelpRefusal();
|
||||
return;
|
||||
}
|
||||
|
||||
string remainder = RemainderAfterFirstArgument(arguments);
|
||||
string levelText = FirstArgument(remainder);
|
||||
int level = RetailStrtolBaseZero(levelText);
|
||||
if (level is < 1 or > 3)
|
||||
{
|
||||
ShowClientLocal(
|
||||
"Please specify a valid officer level as a number between 1 and 3.");
|
||||
return;
|
||||
}
|
||||
|
||||
string title = RemainderAfterFirstArgument(remainder).Trim();
|
||||
_actions.SetAllegianceOfficerTitle((uint)level, title);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceName(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0)
|
||||
_actions.QueryAllegianceName();
|
||||
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.SetAllegianceName(RemainderAfterFirstArgument(arguments).Trim());
|
||||
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ClearAllegianceName();
|
||||
else
|
||||
ShowAllegianceHelpRefusal();
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceLock(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
uint? action = operation.ToLowerInvariant() switch
|
||||
{
|
||||
"" or "check" => 4u,
|
||||
"off" => 1u,
|
||||
"on" => 2u,
|
||||
"toggle" => 3u,
|
||||
_ => null,
|
||||
};
|
||||
if (action is not null)
|
||||
{
|
||||
_actions.AllegianceLockAction(action.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operation.Equals("bypass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ShowAllegianceHelpRefusal();
|
||||
return;
|
||||
}
|
||||
|
||||
string approved = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (approved.Length == 0)
|
||||
_actions.AllegianceLockAction(5u);
|
||||
else if (approved.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.AllegianceLockAction(6u);
|
||||
else
|
||||
_actions.SetAllegianceApprovedVassal(approved);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceHouse(string arguments)
|
||||
{
|
||||
string category = FirstArgument(arguments);
|
||||
if (category.Length == 0)
|
||||
{
|
||||
_actions.AllegianceHouseAction(1u);
|
||||
return;
|
||||
}
|
||||
|
||||
string state = FirstArgument(RemainderAfterFirstArgument(arguments));
|
||||
uint action = (category.ToLowerInvariant(), state.ToLowerInvariant()) switch
|
||||
{
|
||||
("guest", "open") => 2u,
|
||||
("guest", "close") => 3u,
|
||||
("storage", "open") => 4u,
|
||||
("storage", "close") => 5u,
|
||||
_ => 0u,
|
||||
};
|
||||
if (action == 0u)
|
||||
ShowAllegianceHelpRefusal();
|
||||
else
|
||||
_actions.AllegianceHouseAction(action);
|
||||
}
|
||||
|
||||
private void ExecuteAllegianceMotd(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0)
|
||||
_actions.QueryMotd();
|
||||
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.SetMotd(RemainderAfterFirstArgument(arguments).Trim());
|
||||
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ClearMotd();
|
||||
else
|
||||
ShowAllegianceHelpRefusal();
|
||||
}
|
||||
|
||||
private void ExecuteHouseGuests(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
string name = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|
||||
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify the guest's name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.AddPermanentGuest(name);
|
||||
else
|
||||
_actions.RemovePermanentGuest(name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.RemoveAllPermanentGuests();
|
||||
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|
||||
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.RequestFullGuestList();
|
||||
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ModifyAllegianceGuestPermission(true);
|
||||
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ModifyAllegianceGuestPermission(false);
|
||||
else
|
||||
ShowHouseHelpRefusal();
|
||||
}
|
||||
|
||||
private void ExecuteHouseStorage(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
string name = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|
||||
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
ShowClientLocal("Please specify an actual name.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool enabled = operation.Equals("add", StringComparison.OrdinalIgnoreCase);
|
||||
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (enabled)
|
||||
_actions.AddAllStoragePermission();
|
||||
else
|
||||
_actions.RemoveAllStoragePermission();
|
||||
}
|
||||
else
|
||||
{
|
||||
_actions.ChangeStoragePermission(name, enabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.RemoveAllStoragePermission();
|
||||
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|
||||
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.RequestFullGuestList();
|
||||
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ModifyAllegianceStoragePermission(true);
|
||||
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.ModifyAllegianceStoragePermission(false);
|
||||
else
|
||||
ShowHouseHelpRefusal();
|
||||
}
|
||||
|
||||
private void ExecuteHouseBoot(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
ShowHouseHelpRefusal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.BootEveryone();
|
||||
else
|
||||
_actions.BootSpecificHouseGuest(name);
|
||||
}
|
||||
|
||||
private void ExecuteHouseHooks(string arguments)
|
||||
{
|
||||
string state = FirstArgument(arguments);
|
||||
if (state.Equals("on", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.SetHooksVisibility(true);
|
||||
else if (state.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
_actions.SetHooksVisibility(false);
|
||||
else
|
||||
ShowHouseHelpRefusal();
|
||||
}
|
||||
|
||||
private void ShowAllegianceHelpRefusal() => ShowClientLocal(
|
||||
"Please see @help Allegiance for more information on how to use this command.");
|
||||
|
||||
private void ShowHouseHelpRefusal() => ShowClientLocal(
|
||||
"Please see @help House for more information on how to use this command.");
|
||||
|
||||
private void ShowClientLocal(string text) =>
|
||||
_feedback.ShowClientLocalMessage(text);
|
||||
|
||||
private static int RetailStrtolBaseZero(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return 0;
|
||||
|
||||
int index = 0;
|
||||
int sign = 1;
|
||||
if (value[index] is '+' or '-')
|
||||
{
|
||||
if (value[index] == '-')
|
||||
sign = -1;
|
||||
if (++index == value.Length)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int numberBase = 10;
|
||||
if (value[index] == '0')
|
||||
{
|
||||
numberBase = 8;
|
||||
if (index + 2 < value.Length
|
||||
&& value[index + 1] is 'x' or 'X'
|
||||
&& HexDigit(value[index + 2]) >= 0)
|
||||
{
|
||||
numberBase = 16;
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
|
||||
long result = 0;
|
||||
bool sawDigit = false;
|
||||
while (index < value.Length)
|
||||
{
|
||||
int digit = HexDigit(value[index]);
|
||||
if (digit < 0 || digit >= numberBase)
|
||||
break;
|
||||
sawDigit = true;
|
||||
result = Math.Min(
|
||||
(long)int.MaxValue + (sign < 0 ? 1L : 0L),
|
||||
result * numberBase + digit);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (!sawDigit)
|
||||
return 0;
|
||||
long signed = sign < 0 ? -result : result;
|
||||
return (int)Math.Clamp(signed, int.MinValue, int.MaxValue);
|
||||
|
||||
static int HexDigit(char c) => c switch
|
||||
{
|
||||
>= '0' and <= '9' => c - '0',
|
||||
>= 'a' and <= 'f' => c - 'a' + 10,
|
||||
>= 'A' and <= 'F' => c - 'A' + 10,
|
||||
_ => -1,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? trimmed : trimmed[..separator];
|
||||
}
|
||||
|
||||
private static string RemainderAfterFirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,23 @@ public static class RetailClientCommandCatalog
|
|||
"/framerate",
|
||||
"/framerate - Toggles the framerate display.");
|
||||
|
||||
// ClientCommunicationSystem::DoDay @0x005706F0 ignores argc and toggles
|
||||
// LScape::m_fAlwaysDaylight plus PlayerModule::PersistentAtDay.
|
||||
private static readonly Definition Day = AnyArguments(
|
||||
ClientCommandId.TogglePersistentDaylight,
|
||||
"/day",
|
||||
RetailCommandHelpTable.Day);
|
||||
|
||||
// ClientCommunicationSystem::DoRenderOption @0x0057E120 forwards the
|
||||
// complete argv to GraphicsOptions::HandleRenderOption @0x00455C30.
|
||||
// That handler owns its usage/error reporting, so every argument shape
|
||||
// must reach the application executor rather than the generic catalog
|
||||
// validation refusal.
|
||||
private static readonly Definition Render = AnyArguments(
|
||||
ClientCommandId.RenderOption,
|
||||
"/render <option> <value>",
|
||||
RetailCommandHelpTable.Render);
|
||||
|
||||
private static readonly Definition LockUi = NoArguments(
|
||||
ClientCommandId.ToggleUiLock,
|
||||
"/lockui",
|
||||
|
|
@ -444,15 +461,96 @@ public static class RetailClientCommandCatalog
|
|||
"/alh",
|
||||
"@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one.");
|
||||
|
||||
// GameActionAllegianceInfoRequest.Handle — String16L name, empty = self.
|
||||
// GameActionAllegianceInfoRequest.Handle — String16L member name. Retail
|
||||
// rejects an empty argument locally before it constructs this action.
|
||||
// Exact retail help: acclient_2013_pseudo_c.txt:1031214 —
|
||||
// "@allegiance info <name> - Requests information on a member of your
|
||||
// allegiance.\n"
|
||||
private static readonly Definition AllegianceInfo = AnyArguments(
|
||||
ClientCommandId.AllegianceInfo,
|
||||
"/allegiance info [name]",
|
||||
"/allegiance info <name>",
|
||||
"@allegiance info <name> - Requests information on a member of your allegiance.");
|
||||
|
||||
private static readonly Definition AllegianceBoot = AnyArguments(
|
||||
ClientCommandId.AllegianceBoot,
|
||||
"/allegiance boot [-account] <name>",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceBan = AnyArguments(
|
||||
ClientCommandId.AllegianceBan,
|
||||
"/allegiance ban <add|remove|list> [name]",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceChat = AnyArguments(
|
||||
ClientCommandId.AllegianceChat,
|
||||
"/allegiance chat <on|off|kick|gag|ungag> ...",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceBroadcast = AnyArguments(
|
||||
ClientCommandId.AllegianceBroadcast,
|
||||
"/allegiance broadcast <message>",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceOfficer = AnyArguments(
|
||||
ClientCommandId.AllegianceOfficer,
|
||||
"/allegiance officer [add|set|remove|clear|list] ...",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceOfficerTitle = AnyArguments(
|
||||
ClientCommandId.AllegianceOfficerTitle,
|
||||
"/allegiance title [set|clear|list] ...",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceName = AnyArguments(
|
||||
ClientCommandId.AllegianceName,
|
||||
"/allegiance name [set|clear] ...",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceLock = AnyArguments(
|
||||
ClientCommandId.AllegianceLock,
|
||||
"/allegiance lock [on|off|toggle|check|bypass] ...",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceHouse = AnyArguments(
|
||||
ClientCommandId.AllegianceHouse,
|
||||
"/allegiance house [guest|storage] [open|close]",
|
||||
RetailCommandHelpTable.AllegianceOverview);
|
||||
|
||||
private static readonly Definition AllegianceMotd = AnyArguments(
|
||||
ClientCommandId.AllegianceMotd,
|
||||
"/motd [set <text>|clear]",
|
||||
RetailCommandHelpTable.Motd);
|
||||
|
||||
private static readonly Definition HouseOpenStatus = AnyArguments(
|
||||
ClientCommandId.HouseOpenStatus,
|
||||
"/house <open|close>",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
private static readonly Definition HouseStorage = AnyArguments(
|
||||
ClientCommandId.HouseStorage,
|
||||
"/house storage <subcommand>",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
private static readonly Definition HouseBoot = AnyArguments(
|
||||
ClientCommandId.HouseBoot,
|
||||
"/house boot <name|-all>",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
private static readonly Definition HouseBootAll = AnyArguments(
|
||||
ClientCommandId.HouseBootAll,
|
||||
"/house boot_all",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
private static readonly Definition HouseGuests = AnyArguments(
|
||||
ClientCommandId.HouseGuests,
|
||||
"/house guest <subcommand>",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
private static readonly Definition HouseHooks = AnyArguments(
|
||||
ClientCommandId.HouseHooks,
|
||||
"/house hooks <on|off>",
|
||||
RetailCommandHelpTable.HouseOverview);
|
||||
|
||||
// ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail
|
||||
// text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see
|
||||
// @help Allegiance for more information on how to use this command.".
|
||||
|
|
@ -468,8 +566,9 @@ public static class RetailClientCommandCatalog
|
|||
// to the legacy Allegiance channel (0x02000000) — a real chat-visible
|
||||
// bug. TryMatchAllegiance below now claims ownership of "allegiance"/
|
||||
// "all" UNCONDITIONALLY, exactly like retail's registered-command hash
|
||||
// table does, and shows this refusal for every subcommand beyond the
|
||||
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
|
||||
// table does. The recognized subcommands below now route to their
|
||||
// complete retail handlers; only genuinely unknown forms use this
|
||||
// refusal.
|
||||
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
|
||||
ClientCommandId.AllegianceUnrecognizedSubcommand,
|
||||
Usage: "/allegiance <sub>",
|
||||
|
|
@ -477,6 +576,13 @@ public static class RetailClientCommandCatalog
|
|||
ValidateArguments: static _ => false,
|
||||
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
|
||||
|
||||
private static readonly Definition HouseUnrecognizedSubcommand = new(
|
||||
ClientCommandId.HouseUnrecognizedSubcommand,
|
||||
Usage: "/house <sub>",
|
||||
HelpText: "Please see @help House for more information on how to use this command.",
|
||||
ValidateArguments: static _ => false,
|
||||
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
|
||||
|
||||
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
||||
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
|
@ -499,6 +605,8 @@ public static class RetailClientCommandCatalog
|
|||
["age"] = QueryAge,
|
||||
["birth"] = QueryBirth,
|
||||
["framerate"] = FrameRate,
|
||||
["day"] = Day,
|
||||
["render"] = Render,
|
||||
["lockui"] = LockUi,
|
||||
["version"] = Version,
|
||||
["loc"] = Location,
|
||||
|
|
@ -545,6 +653,7 @@ public static class RetailClientCommandCatalog
|
|||
["off"] = OffChannel,
|
||||
["alh"] = AllegianceHometown,
|
||||
["ah"] = AllegianceHometown,
|
||||
["motd"] = AllegianceMotd,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -603,38 +712,69 @@ public static class RetailClientCommandCatalog
|
|||
|
||||
/// <summary>
|
||||
/// <c>@house <sub></c> / <c>@hou <sub></c> dispatcher.
|
||||
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
|
||||
/// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4
|
||||
/// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/
|
||||
/// ma, abandon). Every OTHER subcommand — open, close, storage, remove,
|
||||
/// boot, boot_all, remove_all, guest, available, hooks, on, off, and
|
||||
/// any misspelling of the 4 ported ones — returns <c>false</c>
|
||||
/// uniformly (there is no separate local-swallow branch; CH4
|
||||
/// REJECT-review nit 10, 2026-08-09, corrected this comment, which
|
||||
/// previously described a swallow path that does not exist in the code
|
||||
/// below), letting <see cref="ChatCommandRouter"/> fall through to
|
||||
/// server passthrough (ACE replies "Unknown command") rather than
|
||||
/// being swallowed locally with a wrong usage message — the Tier-1 #4
|
||||
/// fix from the command-registry doc. The 12 unported subcommands are
|
||||
/// tracked by TS-68.
|
||||
/// Retail's real <c>DoHouse @ 0x00580860</c> owns the complete verb and
|
||||
/// dispatches its nested commands locally. Unknown/incomplete forms print
|
||||
/// the exact House refusal rather than escaping to server chat.
|
||||
/// </summary>
|
||||
private static bool TryMatchHouse(string arguments, out Match match)
|
||||
{
|
||||
match = default;
|
||||
string subcommand = arguments.ToLowerInvariant();
|
||||
Definition? definition = subcommand switch
|
||||
int separator = IndexOfWhitespace(arguments);
|
||||
string subcommand = separator < 0 ? arguments : arguments[..separator];
|
||||
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
|
||||
Definition? definition = subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
"recall" or "re" => HouseRecall,
|
||||
"mansion_recall" or "alleg_recall" or "ma" => MansionRecall,
|
||||
"abandon" => HouseAbandon,
|
||||
"open" or "close" => HouseOpenStatus,
|
||||
"storage" => HouseStorage,
|
||||
"remove" or "boot" => HouseBoot,
|
||||
"boot_all" or "remove_all" => HouseBootAll,
|
||||
"guest" => HouseGuests,
|
||||
"available" => HouseAvailableList,
|
||||
"hooks" => HouseHooks,
|
||||
_ => null,
|
||||
};
|
||||
if (definition is null)
|
||||
return false;
|
||||
{
|
||||
match = new Match(
|
||||
HouseUnrecognizedSubcommand.Command,
|
||||
arguments,
|
||||
HouseUnrecognizedSubcommand.Usage,
|
||||
HasValidArguments: false,
|
||||
HouseUnrecognizedSubcommand.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (definition == HouseRecall || definition == MansionRecall)
|
||||
{
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
rest,
|
||||
definition.Usage,
|
||||
HasValidArguments: rest.Length == 0,
|
||||
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (definition == HouseAvailableList)
|
||||
{
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
rest,
|
||||
definition.Usage,
|
||||
definition.ValidateArguments(rest),
|
||||
definition.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
string nestedArguments = definition == HouseOpenStatus
|
||||
? subcommand
|
||||
: rest;
|
||||
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
Arguments: string.Empty,
|
||||
Arguments: nestedArguments,
|
||||
definition.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
|
|
@ -643,25 +783,15 @@ public static class RetailClientCommandCatalog
|
|||
|
||||
/// <summary>
|
||||
/// <c>@allegiance <sub></c> / <c>@all <sub></c> dispatcher.
|
||||
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
|
||||
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
|
||||
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
|
||||
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles all twelve
|
||||
/// subcommands locally. Unknown forms remain locally owned and print its
|
||||
/// exact Allegiance refusal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
|
||||
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
|
||||
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
|
||||
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
|
||||
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
|
||||
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
|
||||
/// prints its own client-local refusal
|
||||
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
|
||||
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
|
||||
/// the server. The original CH4 implementation returned <c>false</c>
|
||||
/// here (matching <see cref="TryMatchHouse"/>'s reasoning), which let
|
||||
/// an unmatched subcommand escape all the way to the unregistered-tag
|
||||
/// channel-fallback and broadcast the raw text to the Allegiance
|
||||
/// channel — a real bug, not merely an incomplete port.
|
||||
/// Retail claims the entire "allegiance"/"all" verb. Recognized forms
|
||||
/// execute locally; unknown forms produce
|
||||
/// <see cref="AllegianceUnrecognizedSubcommand"/> and never escape to
|
||||
/// channel fallback or server passthrough.
|
||||
/// </remarks>
|
||||
private static bool TryMatchAllegiance(string arguments, out Match match)
|
||||
{
|
||||
|
|
@ -669,24 +799,29 @@ public static class RetailClientCommandCatalog
|
|||
string subcommand = separator < 0 ? arguments : arguments[..separator];
|
||||
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
|
||||
|
||||
if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase)
|
||||
|| subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase))
|
||||
Definition? definition = subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
match = new Match(
|
||||
AllegianceHometown.Command,
|
||||
Arguments: string.Empty,
|
||||
AllegianceHometown.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
"boot" => AllegianceBoot,
|
||||
"info" => AllegianceInfo,
|
||||
"chat" or "ch" => AllegianceChat,
|
||||
"broadcast" or "br" => AllegianceBroadcast,
|
||||
"ban" => AllegianceBan,
|
||||
"officer" => AllegianceOfficer,
|
||||
"title" => AllegianceOfficerTitle,
|
||||
"hometown" or "ho" => AllegianceHometown,
|
||||
"motd" => AllegianceMotd,
|
||||
"name" => AllegianceName,
|
||||
"lock" => AllegianceLock,
|
||||
"house" => AllegianceHouse,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
|
||||
if (definition is not null)
|
||||
{
|
||||
match = new Match(
|
||||
AllegianceInfo.Command,
|
||||
rest,
|
||||
AllegianceInfo.Usage,
|
||||
definition.Command,
|
||||
definition == AllegianceHometown ? string.Empty : rest,
|
||||
definition.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ namespace AcDream.Runtime.Chat;
|
|||
/// registers these with NO handler; typing them bare reaches the server,
|
||||
/// only <c>@help <verb></c> shows anything locally), and the
|
||||
/// allegiance/house command overviews (the per-subcommand detail lives
|
||||
/// here too, even though most subcommands are not yet locally executed —
|
||||
/// see TS-68). <b>Corrected at the consolidated-review round
|
||||
/// here too). <b>Corrected at the consolidated-review round
|
||||
/// (2026-08-10), SHOULD-FIX 1:</b> the sentence above previously claimed
|
||||
/// this table covers only verbs <see cref="RetailClientCommandCatalog"/>
|
||||
/// "doesn't dispatch directly" — that framing is now FALSE and was itself
|
||||
|
|
@ -379,9 +378,8 @@ public static class RetailCommandHelpTable
|
|||
// BN already fully decodes with no vtable-slot artifact to work around.
|
||||
// The pristine dump's "broadcast" line ends "...Also: @ab\n" (no
|
||||
// bracket) and the "hometown" line ends "...tied to one.\n" (no
|
||||
// bracket, no alias mention at all) — both removed here. TS-68's
|
||||
// implemented-vs-not-yet-implemented tracking lives in ISSUES.md and
|
||||
// the divergence register now, not in this user-visible string.
|
||||
// bracket, no alias mention at all) — both removed here. Implementation
|
||||
// status never belongs in this verbatim user-visible string.
|
||||
public const string AllegianceOverview =
|
||||
"@allegiance - Commands to help manage your allegiance.\n"
|
||||
+ "@allegiance boot [-account] <name> - Removes a character from your allegiance.\n"
|
||||
|
|
@ -427,8 +425,7 @@ public static class RetailCommandHelpTable
|
|||
// counterpart. The "@house available" line's retail literal is exactly
|
||||
// "@house available - See @hslist\n" — no trailing period, and none of
|
||||
// the bracketed "[see @hslist, IMPLEMENTED]" text the old version
|
||||
// appended. TS-68's implemented-vs-not-yet-implemented tracking lives
|
||||
// in ISSUES.md and the divergence register now, not in this
|
||||
// appended. Implementation status never belongs in this verbatim
|
||||
// user-visible string.
|
||||
public const string HouseOverview =
|
||||
HouseOneLiner
|
||||
|
|
@ -955,6 +952,9 @@ public static class RetailCommandHelpTable
|
|||
// that in place of retail's text is exactly what this table exists
|
||||
// to prevent.
|
||||
["log"] = Log,
|
||||
["day"] = Day,
|
||||
["render"] = Render,
|
||||
["motd"] = Motd,
|
||||
["lifestone"] = LifestoneDetail,
|
||||
["lif"] = LifestoneDetail,
|
||||
["ls"] = LifestoneDetail,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
|
|
@ -597,8 +598,8 @@ public sealed class InboundPhysicsStateController
|
|||
/// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
|
||||
/// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
|
||||
/// of the timestamp disposition and the static HasAnims proxy - which is
|
||||
/// why classifying them here needs no route, no player distance and no
|
||||
/// signature change (see the truth table at the call below). The
|
||||
/// why deriving them here needs no route, no player distance and no
|
||||
/// signature change. The
|
||||
/// near/far/teleport routing decision proper is still downstream of this
|
||||
/// merge and still belongs to
|
||||
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
|
||||
|
|
@ -606,15 +607,9 @@ public sealed class InboundPhysicsStateController
|
|||
/// which the App-layer OnPosition tail runs post-merge for this caller;
|
||||
/// contact still comes solely from the retained wire packet's own
|
||||
/// <c>IsGrounded</c> bit on both. That remaining structural difference
|
||||
/// (two callers computing the same two flags from the same two inputs
|
||||
/// rather than sharing one code path) is internal refactor debt - it is
|
||||
/// NOT a retail divergence and does not belong in
|
||||
/// docs/architecture/retail-divergence-register.md. It is tracked as
|
||||
/// docs/ISSUES.md issue <b>#322</b>, filed 2026-08-05 at the C5b review
|
||||
/// (finding S1): #275 is CLOSED and this comment's former "tracked for
|
||||
/// the eventual cutover unification / see docs/ISSUES.md" wording pointed
|
||||
/// at nothing once it was. #322 also records why widening this method's
|
||||
/// signature to take a whole route would be the wrong unification.
|
||||
/// shared <c>DerivePrePlacementFlags</c> function now owns the truth table
|
||||
/// for both this merge and the downstream route classifier (#322); the
|
||||
/// full route remains downstream because these writes do not depend on it.
|
||||
/// </summary>
|
||||
public bool TryApplyPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
|
|
@ -680,14 +675,6 @@ public sealed class InboundPhysicsStateController
|
|||
// ForcePosition | false | false
|
||||
// Apply | !hasAnimations | true
|
||||
//
|
||||
// That is exactly RuntimeAuthoritativePositionRouteClassifier's own
|
||||
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows
|
||||
// (ClassifyAcceptedPosition: false/false on the force row, and
|
||||
// `!request.HasAnimations`/true on EVERY accepted non-force route).
|
||||
// The classifier stays the oracle; the equality of the two small pure
|
||||
// computations is pinned by test, not by a shared code path, so each
|
||||
// remains separately sabotage-verifiable.
|
||||
bool force = disposition is PositionTimestampDisposition.ForcePosition;
|
||||
// AP-130's static proxy, computed from the PRE-merge snapshot `old`
|
||||
// with the identical expression RuntimeAcceptedPositionRouteRequests
|
||||
// uses. Deliberately NOT a live animation-queue read.
|
||||
|
|
@ -695,6 +682,10 @@ public sealed class InboundPhysicsStateController
|
|||
(old.MotionTableId ?? old.Physics?.MotionTableId)
|
||||
is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
|
||||
RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags(
|
||||
disposition,
|
||||
hasAnimations);
|
||||
accepted = ApplyAcceptedPosition(
|
||||
old,
|
||||
update,
|
||||
|
|
@ -703,8 +694,8 @@ public sealed class InboundPhysicsStateController
|
|||
isLocalPlayer,
|
||||
forcePositionRotation,
|
||||
currentLocalVelocity,
|
||||
installPlacementFrame: !force && !hasAnimations,
|
||||
clearParent: !force);
|
||||
installPlacementFrame: prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
clearParent: prePlacement.UnparentBeforeRouting);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -713,10 +704,8 @@ public sealed class InboundPhysicsStateController
|
|||
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
|
||||
/// executor passes its classified route's own
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
|
||||
/// flags; since C5b (#275) the steady-state
|
||||
/// <see cref="TryApplyPosition"/> caller passes the same two values,
|
||||
/// derived pre-merge from (disposition, hasAnimations) rather than read
|
||||
/// off a route.</summary>
|
||||
/// flags; the steady-state <see cref="TryApplyPosition"/> caller gets the
|
||||
/// same values from the shared pre-placement derivation.</summary>
|
||||
internal bool ApplyAcceptedPositionSnapshot(
|
||||
uint guid,
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
|
|
@ -817,13 +806,9 @@ public sealed class InboundPhysicsStateController
|
|||
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
|
||||
///
|
||||
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
|
||||
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
|
||||
/// BOTH callers supply the same classified values, from the same two
|
||||
/// inputs: the continuation executor reads its classified route's
|
||||
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
|
||||
/// and the steady-state <see cref="TryApplyPosition"/> merge derives them
|
||||
/// pre-merge from (disposition, hasAnimations) - see the truth table
|
||||
/// there. Both are false only for the FORCE_POSITION branch, which
|
||||
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Both callers now
|
||||
/// consume the single <c>DerivePrePlacementFlags</c> truth table. Both
|
||||
/// flags are false only for the FORCE_POSITION branch, which
|
||||
/// retail's HandleReceivedPosition Gate A returns from immediately,
|
||||
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
|
||||
/// additionally false whenever HasAnims is true.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.Properties;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for the House tab of retail's
|
||||
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL —
|
||||
/// "houseless-status only" per ISSUES #413's own sizing note: a full
|
||||
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately lightweight —
|
||||
/// a full
|
||||
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
|
||||
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
|
||||
/// is disproportionate for what this slice needs, since (unlike Trade) this
|
||||
|
|
@ -28,8 +29,8 @@ namespace AcDream.Runtime.Gameplay;
|
|||
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
|
||||
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
|
||||
/// <c>DisplayWarningText</c>) open with <c>if (this->m_pHouseData != 0)</c>
|
||||
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
|
||||
/// item 3.
|
||||
/// and emit NOTHING when houseless. The owned-house path retains the exact
|
||||
/// 0x0225 snapshot and ports all seven builders in retail's fixed order.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>CORRECTED at the 2026-08-17 morning gate round (user finding 2: the
|
||||
|
|
@ -63,7 +64,9 @@ namespace AcDream.Runtime.Gameplay;
|
|||
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
|
||||
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
|
||||
/// (owns-a-house sibling at <c>data_7ab818</c>, byte-re-verified this
|
||||
/// round). So a queried houseless character's House tab shows exactly TWO
|
||||
/// round). The owned branch now composes the retained buy list through the
|
||||
/// retail <c>HousePaymentList</c> rules. So a queried houseless character's
|
||||
/// House tab shows exactly TWO
|
||||
/// lines, in builder order: "You do not currently own a house." then the
|
||||
/// purchase-time line — which this class now renders.
|
||||
/// </para>
|
||||
|
|
@ -101,7 +104,9 @@ public sealed class RuntimeHouseState
|
|||
private readonly object _gate = new();
|
||||
private bool _hasReceivedNotice;
|
||||
private bool _ownsHouse;
|
||||
private GameEvents.HouseData? _houseData;
|
||||
private IReadOnlyList<string> _lines = Array.Empty<string>();
|
||||
private IReadOnlyList<HousePanelLine> _panelLines = Array.Empty<HousePanelLine>();
|
||||
|
||||
/// <summary>Borrows the canonical object table (optional for bare
|
||||
/// fixtures) to read the local player's own
|
||||
|
|
@ -123,6 +128,28 @@ public sealed class RuntimeHouseState
|
|||
get { lock (_gate) return _lines; }
|
||||
}
|
||||
|
||||
/// <summary>The same rows with retail's <c>HousePanelTextColor</c>
|
||||
/// index preserved for the authored row template.</summary>
|
||||
public IReadOnlyList<HousePanelLine> PanelLines
|
||||
{
|
||||
get { lock (_gate) return _panelLines; }
|
||||
}
|
||||
|
||||
/// <summary>The owned-house position used by retail's Map-page house
|
||||
/// marker. Apartments intentionally expose no landscape position.</summary>
|
||||
public CreateObject.ServerPosition? Position
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _houseData is { Type: not 4u, Position: { LandblockId: not 0u } } data
|
||||
? data.Position
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
|
||||
/// arrived this session.</summary>
|
||||
public bool HasReceivedNotice
|
||||
|
|
@ -131,15 +158,48 @@ public sealed class RuntimeHouseState
|
|||
}
|
||||
|
||||
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
|
||||
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
|
||||
/// the owned-house payload itself (buy/rent payments, times, location)
|
||||
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
|
||||
/// (owned-house case). The snapshot is retained defensively because its
|
||||
/// payment lists are later replaced by 0x0227/0x0228 notices.</summary>
|
||||
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_hasReceivedNotice = true;
|
||||
_ownsHouse = true;
|
||||
_houseData = Copy(data);
|
||||
Recompute(selfGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0227 UpdateRentTime. Retail installs the new period start,
|
||||
/// clears every paid count, then rebuilds the complete panel.</summary>
|
||||
public void ApplyRentTime(uint rentTime, uint selfGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_houseData is not { } data)
|
||||
return;
|
||||
|
||||
GameEvents.HousePayment[] rent = data.Rent
|
||||
.Select(static payment => payment with { Paid = 0 })
|
||||
.ToArray();
|
||||
_houseData = data with { RentTime = rentTime, Rent = rent };
|
||||
Recompute(selfGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0228 UpdateRentPayment. Retail replaces the complete rent
|
||||
/// list, then rebuilds the complete panel.</summary>
|
||||
public void ApplyRentPayment(
|
||||
IReadOnlyList<GameEvents.HousePayment> rent, uint selfGuid)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rent);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_houseData is not { } data)
|
||||
return;
|
||||
|
||||
_houseData = data with { Rent = rent.ToArray() };
|
||||
Recompute(selfGuid);
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +218,7 @@ public sealed class RuntimeHouseState
|
|||
{
|
||||
_hasReceivedNotice = true;
|
||||
_ownsHouse = false;
|
||||
_houseData = null;
|
||||
Recompute(selfGuid);
|
||||
}
|
||||
}
|
||||
|
|
@ -171,29 +232,67 @@ public sealed class RuntimeHouseState
|
|||
{
|
||||
_hasReceivedNotice = false;
|
||||
_ownsHouse = false;
|
||||
_houseData = null;
|
||||
_lines = Array.Empty<string>();
|
||||
_panelLines = Array.Empty<HousePanelLine>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
|
||||
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
|
||||
/// houseless branch (first) and <c>DisplayPurchaseTimeText
|
||||
/// @0x004a3110</c> (last), both branches. The five houseless-silent
|
||||
/// builders between them, and DisplayBuyPayment's owned branch, are
|
||||
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
|
||||
/// <summary>Port of <c>gmHouseUI::DisplayHouseData @0x004a3380</c>'s
|
||||
/// fixed seven-builder order. <c>DisplayRentTimes</c> emits two rows, so
|
||||
/// an owned outdoor house produces eight rows total. Must hold
|
||||
/// <see cref="_gate"/>.</summary>
|
||||
private void Recompute(uint selfGuid)
|
||||
{
|
||||
var lines = new List<string>(2);
|
||||
var lines = new List<HousePanelLine>(_ownsHouse ? 8 : 2);
|
||||
|
||||
// gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent
|
||||
// (2026-08-17 morning gate correction; see the class doc): the
|
||||
// m_pHouseData gate only selects WHICH text, and the emit runs in
|
||||
// both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688):
|
||||
// this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase
|
||||
// price for this dwelling is:\n" + HousePaymentList::ComposeText):
|
||||
// unported, #413 item 3 — the owned case adds nothing here yet.
|
||||
if (!_ownsHouse)
|
||||
lines.Add("You do not currently own a house.");
|
||||
// both branches. The owned prefix and every remaining literal were
|
||||
// recovered from the PDB-paired binary for issue #413.
|
||||
if (_houseData is { } data)
|
||||
{
|
||||
lines.Add(Normal(
|
||||
"The purchase price for this dwelling is:\n"
|
||||
+ ComposePayments(data.Buy, includePaid: false)));
|
||||
lines.Add(Normal(
|
||||
"Rent:\n" + ComposePayments(data.Rent, includePaid: true)));
|
||||
lines.Add(Normal("Bought: " + ConvertTime(data.BuyTime)));
|
||||
|
||||
long period = GetRentPeriodSeconds(data.Type);
|
||||
bool paid = data.MaintenanceFree || IsPaidInFull(data.Rent);
|
||||
lines.Add(Normal(
|
||||
"This maintenance period ends: "
|
||||
+ ConvertTime((long)data.RentTime + period)));
|
||||
lines.Add(Normal(
|
||||
"Maintenance is next due: "
|
||||
+ ConvertTime((long)data.RentTime + (paid ? 2L : 1L) * period)));
|
||||
|
||||
if (data.Type != 4u
|
||||
&& RadarCoordinates.TryFromCell(
|
||||
data.Position.LandblockId, out RadarCoordinates coordinates))
|
||||
{
|
||||
lines.Add(Normal(
|
||||
$"Location: {coordinates.YText}, {coordinates.XText}"));
|
||||
}
|
||||
|
||||
lines.Add(paid
|
||||
? new HousePanelLine(
|
||||
"The maintenance has already been paid for this period. "
|
||||
+ "You may not prepay next period's maintenance.",
|
||||
HousePanelTextColor.RentPaid)
|
||||
: new HousePanelLine(
|
||||
"Warning! You have not paid your maintenance costs for the last "
|
||||
+ (period / 86_400L).ToString(CultureInfo.InvariantCulture)
|
||||
+ " day maintenance period. Please pay these costs by this deadline"
|
||||
+ " or you will lose your house, and all your items within it.",
|
||||
HousePanelTextColor.RentNotPaid));
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add(Normal("You do not currently own a house."));
|
||||
}
|
||||
|
||||
// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches.
|
||||
int timestamp = _objects?.Get(selfGuid)?.Properties
|
||||
|
|
@ -221,18 +320,95 @@ public sealed class RuntimeHouseState
|
|||
timestamp + PurchaseWaitPeriodSeconds);
|
||||
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
|
||||
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
|
||||
lines.Add(
|
||||
lines.Add(Normal(
|
||||
"You may buy another landscape house at "
|
||||
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
|
||||
+ ". This restriction does not apply to apartments.");
|
||||
+ ". This restriction does not apply to apartments."));
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add(_ownsHouse
|
||||
lines.Add(Normal(_ownsHouse
|
||||
? "You may buy another house immediately after you abandon this one."
|
||||
: "You may buy another house immediately.");
|
||||
: "You may buy another house immediately."));
|
||||
}
|
||||
|
||||
_lines = lines;
|
||||
_panelLines = lines;
|
||||
_lines = lines.Select(static line => line.Text).ToArray();
|
||||
}
|
||||
|
||||
private HousePanelLine Normal(string text) =>
|
||||
new(text, HousePanelTextColor.Normal);
|
||||
|
||||
private static GameEvents.HouseData Copy(GameEvents.HouseData data) =>
|
||||
data with
|
||||
{
|
||||
Buy = (data.Buy ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
|
||||
Rent = (data.Rent ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
|
||||
};
|
||||
|
||||
private static bool IsPaidInFull(IReadOnlyList<GameEvents.HousePayment> payments)
|
||||
{
|
||||
for (int i = 0; i < payments.Count; i++)
|
||||
{
|
||||
if (payments[i].Paid < payments[i].Num)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ComposePayments(
|
||||
IReadOnlyList<GameEvents.HousePayment> payments, bool includePaid)
|
||||
{
|
||||
if (payments.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var parts = new string[payments.Count];
|
||||
for (int i = 0; i < payments.Count; i++)
|
||||
{
|
||||
GameEvents.HousePayment payment = payments[i];
|
||||
string quantity = includePaid
|
||||
? $"{payment.Paid.ToString(CultureInfo.InvariantCulture)}/{payment.Num.ToString(CultureInfo.InvariantCulture)}"
|
||||
: payment.Num.ToString(CultureInfo.InvariantCulture);
|
||||
parts[i] = quantity + " " + PaymentName(payment);
|
||||
}
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string PaymentName(GameEvents.HousePayment payment)
|
||||
{
|
||||
if (payment.Num == 1)
|
||||
return payment.Name;
|
||||
if (!string.IsNullOrEmpty(payment.PluralName))
|
||||
return payment.PluralName;
|
||||
|
||||
return payment.Name.EndsWith('s') || payment.Name.EndsWith('x')
|
||||
? payment.Name + "es"
|
||||
: payment.Name + "s";
|
||||
}
|
||||
|
||||
private static long GetRentPeriodSeconds(uint houseType) =>
|
||||
houseType == 4u ? 7_776_000L : 2_592_000L;
|
||||
|
||||
private string ConvertTime(long epochSeconds)
|
||||
{
|
||||
if (epochSeconds == 0L)
|
||||
return "N/A";
|
||||
|
||||
DateTimeOffset utc = DateTimeOffset.FromUnixTimeSeconds(epochSeconds);
|
||||
DateTime local = TimeZoneInfo.ConvertTime(
|
||||
utc, _timeProvider.LocalTimeZone).DateTime;
|
||||
return local.ToString(CultureInfo.CurrentCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>HousePanelTextColor</c>; the numeric value is the
|
||||
/// authored row template's font-color palette index.</summary>
|
||||
public enum HousePanelTextColor
|
||||
{
|
||||
Normal = 0,
|
||||
RentPaid = 1,
|
||||
RentNotPaid = 2,
|
||||
}
|
||||
|
||||
public readonly record struct HousePanelLine(
|
||||
string Text, HousePanelTextColor Color);
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest(
|
|||
bool HasAnimations,
|
||||
RuntimePositionPlacementFacts PlacementFacts);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's two writes before <c>MoveOrTeleport</c>: the force-position
|
||||
/// self-echo returns before both; every ordinary accepted Position unparents,
|
||||
/// and only an object without animations receives the wire placement frame.
|
||||
/// These facts depend solely on timestamp disposition and HasAnims.
|
||||
/// </summary>
|
||||
internal readonly record struct RuntimeAcceptedPositionPrePlacementFlags(
|
||||
bool UnparentBeforeRouting,
|
||||
bool ApplyPlacementFrameBeforeRouting);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport.
|
||||
/// It deliberately contains no renderer, world entity, UI, or host callback.
|
||||
|
|
@ -189,6 +199,20 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
internal static bool IsValidCreateWirePosition(
|
||||
in CreateObject.ServerPosition position) => ValidPosition(position);
|
||||
|
||||
internal static RuntimeAcceptedPositionPrePlacementFlags DerivePrePlacementFlags(
|
||||
PositionTimestampDisposition disposition,
|
||||
bool hasAnimations) => disposition switch
|
||||
{
|
||||
PositionTimestampDisposition.Apply => new(
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !hasAnimations),
|
||||
PositionTimestampDisposition.ForcePosition => default,
|
||||
// Rejected packets use the timestamp-only merge and never inspect
|
||||
// either flag. Returning default keeps that branch explicit.
|
||||
PositionTimestampDisposition.Rejected => default,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(disposition), disposition, null),
|
||||
};
|
||||
|
||||
internal static RuntimeAuthoritativePositionRoute ClassifyCreate(
|
||||
in RuntimeCreatePositionRouteRequest request)
|
||||
{
|
||||
|
|
@ -308,6 +332,10 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
if (!ValidPosition(request.AcceptedWirePosition))
|
||||
return RejectedData(request.Authority, operation, reporting);
|
||||
|
||||
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
|
||||
DerivePrePlacementFlags(
|
||||
request.Authority.TimestampDisposition,
|
||||
request.HasAnimations);
|
||||
bool force = request.Authority.TimestampDisposition
|
||||
is PositionTimestampDisposition.ForcePosition;
|
||||
if (force)
|
||||
|
|
@ -320,8 +348,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
AuthoritativeTeleportFlags,
|
||||
request.PlacementFrame ?? 0u,
|
||||
UnparentBeforeRouting: false,
|
||||
ApplyPlacementFrameBeforeRouting: false,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.None,
|
||||
StopInterpolating: false,
|
||||
|
|
@ -343,8 +371,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
AuthoritativeTeleportFlags,
|
||||
placement,
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation,
|
||||
StopInterpolating: false,
|
||||
|
|
@ -365,8 +393,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
PhysicsSetPositionFlags.None,
|
||||
placement,
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.None,
|
||||
StopInterpolating: false,
|
||||
|
|
@ -396,8 +424,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
AuthoritativeTeleportFlags,
|
||||
placement,
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation,
|
||||
StopInterpolating: false,
|
||||
|
|
@ -422,8 +450,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
PhysicsSetPositionFlags.None,
|
||||
placement,
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.None,
|
||||
StopInterpolating: false,
|
||||
|
|
@ -452,8 +480,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
operation,
|
||||
nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags,
|
||||
placement,
|
||||
UnparentBeforeRouting: true,
|
||||
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
|
||||
prePlacement.UnparentBeforeRouting,
|
||||
prePlacement.ApplyPlacementFrameBeforeRouting,
|
||||
LeaveWorld: false,
|
||||
TeleportHookPhase: RuntimeTeleportHookPhase.None,
|
||||
StopInterpolating: !nearby,
|
||||
|
|
@ -480,9 +508,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
|
|||
{
|
||||
PositionTimestampDisposition.Apply => true,
|
||||
PositionTimestampDisposition.ForcePosition =>
|
||||
kind is RuntimePositionEntityKind.LocalPlayer
|
||||
&& authority.PreviousTeleportSequence
|
||||
== authority.AcceptedTeleportSequence,
|
||||
kind is RuntimePositionEntityKind.LocalPlayer,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -597,6 +597,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
_deferredByCellGeneration = [];
|
||||
private SortedDictionary<ulong, RuntimePlacementProjectionSnapshot>
|
||||
_pendingProjection = [];
|
||||
// Issue #311: retry is a per-tick pump while a host receipt remains
|
||||
// unacknowledged. Retain one snapshot list per synchronous call depth
|
||||
// instead of allocating Values.ToArray() every tick. PublishPlacement can
|
||||
// invoke arbitrary observers, so the depth-indexed shape preserves the
|
||||
// old snapshot semantics even if an observer re-enters this method.
|
||||
private readonly List<List<RuntimePlacementProjectionSnapshot>>
|
||||
_pendingProjectionRetryScratchByDepth = [new()];
|
||||
private int _pendingProjectionRetryDepth;
|
||||
private readonly List<CellGenerationKey> _deferredBucketOrder = [];
|
||||
private readonly Dictionary<UnboundCellKey, List<RuntimeEntityKey>>
|
||||
_unboundDeferredByCell = [];
|
||||
|
|
@ -934,6 +942,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
|
||||
if (HasPendingProjectionThrough(current.ProjectionBarrierSequence))
|
||||
return false;
|
||||
// #310: collision retirement is the stronger authority for an
|
||||
// authored mover that is still waiting for its first preparation.
|
||||
// Such an operation has no canonical placement result or projection
|
||||
// to preserve; waiting for an asset that may never resolve used to
|
||||
// pin this prefix forever. Cancel the exact unprepared operation
|
||||
// before evaluating ordinary placement debt, then let the resident
|
||||
// enter the retirement park below.
|
||||
CancelUnpreparedPrefixPlacementDebt(current);
|
||||
if (HasOldPrefixPlacementDebt(current))
|
||||
return false;
|
||||
|
||||
|
|
@ -1117,18 +1133,42 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
internal void RetryPendingProjections()
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
RuntimePlacementProjectionSnapshot[] snapshot =
|
||||
_pendingProjection.Values.ToArray();
|
||||
for (int index = 0; index < snapshot.Length; index++)
|
||||
int depth = _pendingProjectionRetryDepth;
|
||||
if (depth == _pendingProjectionRetryScratchByDepth.Count)
|
||||
{
|
||||
RuntimePlacementProjectionSnapshot projection = snapshot[index];
|
||||
if (_pendingProjection.TryGetValue(
|
||||
projection.Token.Sequence,
|
||||
out RuntimePlacementProjectionSnapshot current)
|
||||
&& current == projection)
|
||||
_pendingProjectionRetryScratchByDepth.Add([]);
|
||||
}
|
||||
List<RuntimePlacementProjectionSnapshot> snapshot =
|
||||
_pendingProjectionRetryScratchByDepth[depth];
|
||||
_pendingProjectionRetryDepth = depth + 1;
|
||||
try
|
||||
{
|
||||
snapshot.Clear();
|
||||
// Enumerate the dictionary itself. SortedDictionary.Values exposes
|
||||
// its enumerator through an interface and boxes it (~72 B/call),
|
||||
// which would retain a smaller version of the allocation this
|
||||
// issue removes.
|
||||
foreach (KeyValuePair<ulong, RuntimePlacementProjectionSnapshot>
|
||||
entry in _pendingProjection)
|
||||
{
|
||||
PublishPlacement(projection);
|
||||
snapshot.Add(entry.Value);
|
||||
}
|
||||
for (int index = 0; index < snapshot.Count; index++)
|
||||
{
|
||||
RuntimePlacementProjectionSnapshot projection = snapshot[index];
|
||||
if (_pendingProjection.TryGetValue(
|
||||
projection.Token.Sequence,
|
||||
out RuntimePlacementProjectionSnapshot current)
|
||||
&& current == projection)
|
||||
{
|
||||
PublishPlacement(projection);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
snapshot.Clear();
|
||||
_pendingProjectionRetryDepth = depth;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4101,6 +4141,50 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
private void CancelUnpreparedPrefixPlacementDebt(
|
||||
CollisionPrefixQuiescence state)
|
||||
{
|
||||
uint prefix = state.Token.LandblockPrefix;
|
||||
List<RuntimeEntityKey>? cancelled = null;
|
||||
foreach (Operation operation in _operations.Values)
|
||||
{
|
||||
if (operation.WakeableLostCell
|
||||
|| operation.DormantLocalActivation
|
||||
|| operation.Stage is not RuntimeEntityPlacementStage
|
||||
.AwaitingPreparation
|
||||
|| !_moverPreparationAuthorities.TryGetValue(
|
||||
operation.Key,
|
||||
out MoverPreparationAuthority preparation)
|
||||
|| preparation.OperationId != operation.Token.OperationId
|
||||
|| preparation.Prepared
|
||||
|| !((preparation.AcceptedPosition.LandblockId
|
||||
& 0xFFFF0000u) == prefix
|
||||
|| IsAffectedCollisionResident(
|
||||
operation.Record,
|
||||
prefix,
|
||||
state.IncludeOutdoorCells)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(cancelled ??= []).Add(operation.Key);
|
||||
}
|
||||
|
||||
if (cancelled is null)
|
||||
return;
|
||||
|
||||
for (int index = 0; index < cancelled.Count; index++)
|
||||
{
|
||||
_ = CancelCoreDeferred(
|
||||
cancelled[index],
|
||||
cancelLostFamily: false,
|
||||
preserveLostFamily: false,
|
||||
out RuntimePlacementProjectionSnapshot? discard);
|
||||
if (discard is { } projection)
|
||||
PublishPlacement(projection);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PlacementTouchesPrefix(
|
||||
in PhysicsSetPositionRequest request,
|
||||
uint prefix) =>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ public sealed record LiveSocialSessionBindings(
|
|||
RuntimeHouseState? House = null,
|
||||
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
|
||||
// trailing/optional compatibility convention.
|
||||
RuntimeContractState? Contracts = null);
|
||||
RuntimeContractState? Contracts = null,
|
||||
// Issue #359: retail suppresses 0x019E PlayerKilled for the victim and
|
||||
// killer. Optional for compatibility with state-only tests; production
|
||||
// hosts always supply their canonical identity owner.
|
||||
Func<uint>? PlayerGuid = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
|
|
@ -338,6 +342,12 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onHouseStatus: social.House is { } houseStatus
|
||||
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
|
||||
: null,
|
||||
onHouseUpdateRentTime: social.House is { } houseRentTime
|
||||
? rentTime => houseRentTime.ApplyRentTime(rentTime, inventory.PlayerGuid())
|
||||
: null,
|
||||
onHouseUpdateRentPayment: social.House is { } houseRentPayment
|
||||
? rent => houseRentPayment.ApplyRentPayment(rent, inventory.PlayerGuid())
|
||||
: null,
|
||||
// Campaign QT (2026-08-21): same conditional delegate-hole
|
||||
// discipline as house above.
|
||||
onContractTable: social.Contracts is { } contractTable
|
||||
|
|
@ -451,7 +461,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
killed => social.Chat.OnPlayerKilled(
|
||||
killed.DeathMessage,
|
||||
killed.VictimGuid,
|
||||
killed.KillerGuid));
|
||||
killed.KillerGuid,
|
||||
social.PlayerGuid?.Invoke() ?? 0u));
|
||||
Subscribe<TurbineChat.Parsed>(
|
||||
h => session.TurbineChatReceived += h,
|
||||
h => session.TurbineChatReceived -= h,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue