fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -1,3 +1,4 @@
using System.Globalization;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
@ -552,11 +553,355 @@ public sealed class ClientCommandControllerTests
Assert.Equal("my chat log.txt", match.Arguments);
}
// ── #361: retail's @day / @render ─────────────────────────────────
[Fact]
public void Day_TogglesThePersistentOptionAndPrintsRetailsExactLines()
{
bool persistentDaylight = false;
var values = new List<bool>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
isPersistentDaylight: () => persistentDaylight,
setPersistentDaylight: value =>
{
persistentDaylight = value;
values.Add(value);
});
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
"ignored exactly like retail"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
string.Empty));
Assert.Equal([true, false], values);
Assert.Equal(
["Let there be light!", "Normality has been restored."],
messages);
}
[Theory]
[InlineData("radius 5", 5)]
[InlineData("RADIUS 25 extra ignored", 25)]
[InlineData("radius 12suffix", 12)]
public void RenderRadius_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
int expected)
{
var radii = new List<int>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setLandscapeRadius: radii.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], radii);
Assert.Equal(["Landscape radius set"], messages);
}
[Theory]
[InlineData("fov 10", 10f)]
[InlineData("FOV 160 extra", 160f)]
[InlineData("fov 91degrees", 91f)]
public void RenderFov_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
float expected)
{
var values = new List<float>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setFieldOfView: values.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], values);
Assert.Equal(["Field of view set"], messages);
}
[Theory]
[InlineData("radius", "Must specify a radius")]
[InlineData("radius 4", "Radius must be between 5 and 25")]
[InlineData("radius nope", "Radius must be between 5 and 25")]
[InlineData("fov", "Must specify a field of view")]
[InlineData("fov 161", "Field of view must be between 10 and 160")]
public void Render_InvalidValuesPrintRetailsExactReply(
string arguments,
string expected)
{
var calls = new List<string>();
var messages = new List<string>();
ClientCommandController controller = NewController(
calls,
messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.DoesNotContain(calls, call =>
call.StartsWith("radius:", StringComparison.Ordinal)
|| call.StartsWith("fov:", StringComparison.Ordinal));
Assert.Equal([expected], messages);
}
[Fact]
public void Render_UsageAndUnknownOptionMatchRetail()
{
var messages = new List<string>();
ClientCommandController controller = NewController(messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
string.Empty));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"usage"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"unknown 1"));
string usage = RetailCommandHelpTable.Render.TrimEnd('\n');
Assert.Equal([usage, usage], messages);
}
[Fact]
public void AllegianceAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
Execute(ClientCommandId.AllegianceInfo, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "-account Account Bob");
// Retail validates non-empty BEFORE removing -account, so this odd
// form deliberately sends an empty name with accountBoot=true.
Execute(ClientCommandId.AllegianceBoot, "-account");
Execute(ClientCommandId.AllegianceBan, "list ignored");
Execute(ClientCommandId.AllegianceBan, "add Lord Bob");
Execute(ClientCommandId.AllegianceBan, "remove Lord Bob");
Execute(ClientCommandId.AllegianceChat, "on");
Execute(ClientCommandId.AllegianceChat, "off");
Execute(ClientCommandId.AllegianceChat, "kick Bob");
Execute(ClientCommandId.AllegianceChat, "kick Bob, Bad manners");
Execute(ClientCommandId.AllegianceChat, "kick ");
Execute(ClientCommandId.AllegianceChat, "gag Lord Bob");
Execute(ClientCommandId.AllegianceChat, "ungag Lord Bob");
Execute(ClientCommandId.AllegianceBroadcast, "Hear ye");
Execute(ClientCommandId.AllegianceOfficer, "");
Execute(ClientCommandId.AllegianceOfficer, "clear ignored");
Execute(ClientCommandId.AllegianceOfficer, "remove Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "add 0x2 Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "set 03 Aunt Alice");
Execute(ClientCommandId.AllegianceOfficerTitle, "");
Execute(ClientCommandId.AllegianceOfficerTitle, "clear ignored");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 0x2 High Regent");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 1");
Execute(ClientCommandId.AllegianceName, "");
Execute(ClientCommandId.AllegianceName, "set The Best Allegiance");
Execute(ClientCommandId.AllegianceName, "set");
Execute(ClientCommandId.AllegianceName, "clear ignored");
Execute(ClientCommandId.AllegianceLock, "");
Execute(ClientCommandId.AllegianceLock, "off");
Execute(ClientCommandId.AllegianceLock, "on");
Execute(ClientCommandId.AllegianceLock, "toggle");
Execute(ClientCommandId.AllegianceLock, "check");
Execute(ClientCommandId.AllegianceLock, "bypass");
Execute(ClientCommandId.AllegianceLock, "bypass clear");
Execute(ClientCommandId.AllegianceLock, "bypass Lord Bob");
Execute(ClientCommandId.AllegianceHouse, "");
Execute(ClientCommandId.AllegianceHouse, "guest open");
Execute(ClientCommandId.AllegianceHouse, "guest close");
Execute(ClientCommandId.AllegianceHouse, "storage open");
Execute(ClientCommandId.AllegianceHouse, "storage close");
Execute(ClientCommandId.AllegianceMotd, "");
Execute(ClientCommandId.AllegianceMotd, "set Welcome everyone");
Execute(ClientCommandId.AllegianceMotd, "set");
Execute(ClientCommandId.AllegianceMotd, "clear ignored");
Assert.Equal(
[
"alleginfo:Lord Bob",
"allegboot:Lord Bob:False",
"allegboot:Account Bob:True",
"allegboot::True",
"allegban:list",
"allegban:add:Lord Bob",
"allegban:remove:Lord Bob",
"charoption:27:True",
"charoption:27:False",
"allegchatboot:Bob:No reason given.",
"allegchatboot:Bob:Bad manners",
"allegchatboot::No reason given.",
"allegchatgag:Lord Bob:True",
"allegchatgag:Lord Bob:False",
"allegbroadcast:Hear ye",
"allegofficer:list",
"allegofficer:clear",
"allegofficer:remove:Lord Bob",
"allegofficer:set:2:Lord Bob",
"allegofficer:set:3:Aunt Alice",
"allegtitle:list",
"allegtitle:clear",
"allegtitle:set:2:High Regent",
"allegtitle:set:1:",
"allegname:query",
"allegname:set:The Best Allegiance",
"allegname:set:",
"allegname:clear",
"alleglock:4",
"alleglock:1",
"alleglock:2",
"alleglock:3",
"alleglock:4",
"alleglock:5",
"alleglock:6",
"alleglock:bypass:Lord Bob",
"alleghouse:1",
"alleghouse:2",
"alleghouse:3",
"alleghouse:4",
"alleghouse:5",
"motd:query",
"motd:set:Welcome everyone",
"motd:set:",
"motd:clear",
],
calls);
Assert.Equal(
[
"Attempting to boot Lord Bob...",
"Attempting to boot Account Bob (Account)...",
"Attempting to boot (Account)...",
],
system);
Assert.Empty(clientLocal);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Fact]
public void HouseAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
ClientCommandController controller = NewController(calls: calls);
Execute(ClientCommandId.HouseOpenStatus, "open");
Execute(ClientCommandId.HouseOpenStatus, "close");
Execute(ClientCommandId.HouseGuests, "add Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove_all ignored");
Execute(ClientCommandId.HouseGuests, "list ignored");
Execute(ClientCommandId.HouseGuests, "show ignored");
Execute(ClientCommandId.HouseGuests, "add_allegiance ignored");
Execute(ClientCommandId.HouseGuests, "remove_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "add Lord Bob");
Execute(ClientCommandId.HouseStorage, "remove Lord Bob");
Execute(ClientCommandId.HouseStorage, "add -all");
Execute(ClientCommandId.HouseStorage, "remove -all");
Execute(ClientCommandId.HouseStorage, "remove_all ignored");
Execute(ClientCommandId.HouseStorage, "list ignored");
Execute(ClientCommandId.HouseStorage, "show ignored");
Execute(ClientCommandId.HouseStorage, "add_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "remove_allegiance ignored");
Execute(ClientCommandId.HouseBoot, "Lord Bob");
Execute(ClientCommandId.HouseBoot, "-all");
Execute(ClientCommandId.HouseBootAll, "ignored");
Execute(ClientCommandId.HouseHooks, "on ignored");
Execute(ClientCommandId.HouseHooks, "off ignored");
Assert.Equal(
[
"houseopen:True",
"houseopen:False",
"houseguest:add:Lord Bob",
"houseguest:remove:Lord Bob",
"houseguest:remove_all",
"houseguest:list",
"houseguest:list",
"houseguest:allegiance:True",
"houseguest:allegiance:False",
"housestorage:True:Lord Bob",
"housestorage:False:Lord Bob",
"housestorage:add_all",
"housestorage:remove_all",
"housestorage:remove_all",
"houseguest:list",
"houseguest:list",
"housestorage:allegiance:True",
"housestorage:allegiance:False",
"houseboot:Lord Bob",
"houseboot:all",
"houseboot:all",
"househooks:True",
"househooks:False",
],
calls);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Theory]
[InlineData(ClientCommandId.AllegianceInfo, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBoot, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBan, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceChat, "gag", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBroadcast, "", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceOfficer, "remove", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add nope Bob", "Please specify a valid officer level as a number between 1 and 3. Check the game help files for more information on officer levels.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add 2", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficerTitle, "set 4 Regent", "Please specify a valid officer level as a number between 1 and 3.")]
[InlineData(ClientCommandId.AllegianceName, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceLock, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceHouse, "guest nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceMotd, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceUnrecognizedSubcommand, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseGuests, "add", "Please specify the guest's name.")]
[InlineData(ClientCommandId.HouseStorage, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.HouseBoot, "", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseHooks, "maybe", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseUnrecognizedSubcommand, "nope", "Please see @help House for more information on how to use this command.")]
public void AdministrationInvalidForms_EmitExactRetailClientLocalText(
ClientCommandId command,
string arguments,
string expected)
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
Assert.Empty(calls);
Assert.Empty(system);
Assert.Equal([expected], clientLocal);
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
uint? playerBitfield = 0x02000028u,
List<string>? messages = null,
List<string>? clientLocalMessages = null,
bool isAway = false,
bool acceptsLootPermits = true,
FriendsState? friends = null,
@ -569,11 +914,16 @@ public sealed class ClientCommandControllerTests
// Defaults to "always accept" so every pre-existing single-stage
// test (Die, etc.) keeps its original behavior unchanged.
Queue<bool>? confirmationResponses = null,
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null,
Func<bool>? isPersistentDaylight = null,
Action<bool>? setPersistentDaylight = null,
Action<int>? setLandscapeRadius = null,
Action<float>? setFieldOfView = null)
{
calls ??= [];
errors ??= [];
messages ??= [];
clientLocalMessages ??= messages;
return new ClientCommandController(new ClientCommandController.Bindings(
() => calls.Add("ls"),
() => calls.Add("mp"),
@ -586,6 +936,7 @@ public sealed class ClientCommandControllerTests
() => calls.Add("fps"),
() => calls.Add("lock"),
messages.Add,
clientLocalMessages.Add,
errors.Add,
() => playerBitfield,
() => "1.2.3",
@ -653,6 +1004,61 @@ public sealed class ClientCommandControllerTests
channelId => calls.Add("off:" + channelId),
() => calls.Add("alh"),
name => calls.Add("alleginfo:" + name),
() => calls.Add("houseabandon")));
() => calls.Add("houseabandon"),
NewAdministrationBindings(calls),
isPersistentDaylight ?? (() => false),
setPersistentDaylight ?? (value => calls.Add("day:" + value)),
setLandscapeRadius ?? (value => calls.Add("radius:" + value)),
setFieldOfView ?? (value => calls.Add(
"fov:" + value.ToString(CultureInfo.InvariantCulture)))));
}
private static ClientCommandController.AdministrationBindings
NewAdministrationBindings(List<string> calls) => new(
BreakAllegianceBoot: (name, account) =>
calls.Add($"allegboot:{name}:{account}"),
AllegianceChatBoot: (name, reason) =>
calls.Add($"allegchatboot:{name}:{reason}"),
AllegianceChatGag: (name, enabled) =>
calls.Add($"allegchatgag:{name}:{enabled}"),
AllegianceBroadcast: text => calls.Add("allegbroadcast:" + text),
ListAllegianceBans: () => calls.Add("allegban:list"),
AddAllegianceBan: name => calls.Add("allegban:add:" + name),
RemoveAllegianceBan: name => calls.Add("allegban:remove:" + name),
ListAllegianceOfficers: () => calls.Add("allegofficer:list"),
ClearAllegianceOfficers: () => calls.Add("allegofficer:clear"),
SetAllegianceOfficer: (name, level) =>
calls.Add($"allegofficer:set:{level}:{name}"),
RemoveAllegianceOfficer: name =>
calls.Add("allegofficer:remove:" + name),
ListAllegianceOfficerTitles: () => calls.Add("allegtitle:list"),
ClearAllegianceOfficerTitles: () => calls.Add("allegtitle:clear"),
SetAllegianceOfficerTitle: (level, title) =>
calls.Add($"allegtitle:set:{level}:{title}"),
QueryAllegianceName: () => calls.Add("allegname:query"),
SetAllegianceName: name => calls.Add("allegname:set:" + name),
ClearAllegianceName: () => calls.Add("allegname:clear"),
AllegianceLockAction: action => calls.Add("alleglock:" + action),
SetAllegianceApprovedVassal: name =>
calls.Add("alleglock:bypass:" + name),
AllegianceHouseAction: action => calls.Add("alleghouse:" + action),
QueryMotd: () => calls.Add("motd:query"),
SetMotd: text => calls.Add("motd:set:" + text),
ClearMotd: () => calls.Add("motd:clear"),
SetOpenHouseStatus: open => calls.Add("houseopen:" + open),
AddPermanentGuest: name => calls.Add("houseguest:add:" + name),
RemovePermanentGuest: name => calls.Add("houseguest:remove:" + name),
RemoveAllPermanentGuests: () => calls.Add("houseguest:remove_all"),
ChangeStoragePermission: (name, enabled) =>
calls.Add($"housestorage:{enabled}:{name}"),
AddAllStoragePermission: () => calls.Add("housestorage:add_all"),
RemoveAllStoragePermission: () => calls.Add("housestorage:remove_all"),
RequestFullGuestList: () => calls.Add("houseguest:list"),
BootSpecificHouseGuest: name => calls.Add("houseboot:" + name),
BootEveryone: () => calls.Add("houseboot:all"),
SetHooksVisibility: visible => calls.Add("househooks:" + visible),
ModifyAllegianceGuestPermission: enabled =>
calls.Add("houseguest:allegiance:" + enabled),
ModifyAllegianceStoragePermission: enabled =>
calls.Add("housestorage:allegiance:" + enabled));
}