CT-B4 was filed as "the plain-text session chat log, path and rotation UNKNOWN, needs a live check." Both unknowns dissolve once you read the handler: there is no automatic session log. Retail's @log is a COMMAND. DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0 does the fopen(name, "a+"), and running it again with no argument closes it. Nothing rotates because it appends forever, and nothing has a fixed path because the player names the file. The path question that DOES exist — where a bare name lands — was answered all along by retail's own help text, which CH4 extracted verbatim into our help table a fortnight ago and nobody read: "a log file named Aclog.txt in your Asheron's Call directory." A blocked question sat on top of a committed answer. We cannot use the install directory: the launcher replaces it atomically on update, so a log written there is wiped by the next update or blocks it. The client's own log directory is the equivalent that survives. Rooted paths are honoured verbatim, as retail's fopen would. Register CT-5. The verb was registered in the help table but NOT in the command catalog, so /log printed help and did nothing — and the CH4 conformance registry recorded it as a "server passthrough" precisely because that shape is indistinguishable from an unimplemented client command. It never went on the wire at all. Both are corrected, with the totals moved in the same commit rather than left to drift. Moving it into the catalog also moves which help table answers for it, so retail's real text moved to the catalog-verb table in the same change. Without that, /help log would have silently started printing acdream's own invented one-line summary — caught by the coverage test, and now pinned by a test that names the text. All five replies are byte-decoded from the PDB-paired binary rather than read off Binary Ninja's previews, which truncate at ~33 characters and would have lost the second half of every one of them (including the two spaces retail puts after "Copying chat to %s."). The writer attaches on OPEN, not at startup — retail's help is explicit that only what appears after the command is copied — and detaches from the transcript it actually attached to, so a session teardown cannot leave a live handler writing into a file the player believes is closed. What gets written is the composed display line with the shared timestamp, because retail's fprintf sits inside AddTextToScroll: downstream of composition, upstream of glyph layout. Logging the raw entry text would have produced a file of bare fragments with no speakers. acdream's logs carry no inline tag markup where retail's do, since tags live beside the text as spans here rather than inside it. Registered as CT-6 rather than reconstructed purely to write it to a file. Register: CT-5, CT-6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
658 lines
25 KiB
C#
658 lines
25 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Social;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public sealed class ClientCommandControllerTests
|
|
{
|
|
[Fact]
|
|
public void RecallAndQueryCommands_ExecuteTheirExactBindings()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls: calls);
|
|
|
|
Execute(ClientCommandId.LifestoneRecall);
|
|
Execute(ClientCommandId.MarketplaceRecall);
|
|
Execute(ClientCommandId.PkArenaRecall);
|
|
Execute(ClientCommandId.PkLiteArenaRecall);
|
|
Execute(ClientCommandId.HouseRecall);
|
|
Execute(ClientCommandId.MansionRecall);
|
|
Execute(ClientCommandId.QueryAge);
|
|
Execute(ClientCommandId.QueryBirth);
|
|
|
|
Assert.Equal(
|
|
["ls", "mp", "pka", "pla", "house", "mansion", "age", "birth"],
|
|
calls);
|
|
|
|
void Execute(ClientCommandId id) => controller.Execute(
|
|
new ExecuteClientCommandCmd(id, Arguments: string.Empty));
|
|
}
|
|
|
|
[Fact]
|
|
public void PkArena_NonPk_ShowsRetailFailureWithoutSending()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.PkArenaRecall, string.Empty));
|
|
|
|
Assert.Empty(calls);
|
|
Assert.Equal([0x055Fu], errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void PkLiteArena_NonPkLite_ShowsRetailFailureWithoutSending()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.PkLiteArenaRecall, string.Empty));
|
|
|
|
Assert.Empty(calls);
|
|
Assert.Equal([0x0560u], errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnterPkLite_NonPk_SendsTheRetailGameAction()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.EnterPkLite, string.Empty));
|
|
|
|
Assert.Equal(["pklite"], calls);
|
|
Assert.Empty(errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnterPkLite_AlreadyPk_ShowsRetailFailureWithoutSending()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
// 0x20 = ACCWeenieObject::IsPK bit only.
|
|
var controller = NewController(calls, errors, playerBitfield: 0x20u);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.EnterPkLite, string.Empty));
|
|
|
|
Assert.Empty(calls);
|
|
Assert.Equal([0x0507u], errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnterPkLite_AlreadyPkLite_ShowsRetailFailureWithoutSending()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
// 0x2000000 = ACCWeenieObject::IsPKLite bit only.
|
|
var controller = NewController(calls, errors, playerBitfield: 0x2000000u);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.EnterPkLite, string.Empty));
|
|
|
|
Assert.Empty(calls);
|
|
Assert.Equal([0x0507u], errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnterPkLite_UnknownPlayerDescription_SendsRatherThanRejects()
|
|
{
|
|
// Tri-state decision: retail's DoPKLite only rejects when
|
|
// IsPlayerKiller() is TRUE. An indeterminate PublicWeenieDesc (not
|
|
// yet arrived) must send, not reject -- the opposite direction from
|
|
// the arena recalls, which gate on the flag being known-true.
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors, playerBitfield: null);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.EnterPkLite, string.Empty));
|
|
|
|
Assert.Equal(["pklite"], calls);
|
|
Assert.Empty(errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingPlayerDescription_DoesNotInventAClientRejection()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls, playerBitfield: null);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.PkArenaRecall, string.Empty));
|
|
|
|
Assert.Equal(["pka"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void LocalPresentationCommands_UseApplicationServices()
|
|
{
|
|
var calls = new List<string>();
|
|
var messages = new List<string>();
|
|
var controller = NewController(calls, messages: messages);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleFrameRate, ""));
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleUiLock, ""));
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ShowVersion, ""));
|
|
|
|
Assert.Equal(["fps", "lock"], calls);
|
|
Assert.Equal(["Client version 1.2.3"], messages);
|
|
}
|
|
|
|
[Fact]
|
|
public void Die_RequiresRetailConfirmationBeforeSuicide()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Die, ""));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"confirm:Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
|
|
"suicide",
|
|
],
|
|
calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChatAndLayoutCommands_UseNamedAndAutomaticPersistenceBindings()
|
|
{
|
|
var calls = new List<string>();
|
|
var messages = new List<string>();
|
|
var controller = NewController(calls, messages: messages);
|
|
|
|
Execute(ClientCommandId.ClearChat, "all");
|
|
Execute(ClientCommandId.SaveUi, "hunt");
|
|
Execute(ClientCommandId.LoadUi, "hunt");
|
|
Execute(ClientCommandId.SaveAutoUi, string.Empty);
|
|
Execute(ClientCommandId.LoadAutoUi, string.Empty);
|
|
Execute(ClientCommandId.SaveUi, "this-name-is-over-16-characters");
|
|
|
|
Assert.Equal(
|
|
["clear:True", "saveui:hunt", "loadui:hunt", "saveautoui", "loadautoui"],
|
|
calls);
|
|
Assert.Equal(["The file name must be 16 characters or less."], messages);
|
|
|
|
void Execute(ClientCommandId id, string arguments) =>
|
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
|
}
|
|
|
|
[Fact]
|
|
public void AwayCommands_RespectCurrentModeAndPackRetailMessageForm()
|
|
{
|
|
var calls = new List<string>();
|
|
var messages = new List<string>();
|
|
var controller = NewController(calls, messages: messages, isAway: false);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "on"));
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "msg Stepped away"));
|
|
|
|
Assert.Equal(["afk:True", "afkmsg:Stepped away\n"], calls);
|
|
Assert.Equal(["New AFK message set: Stepped away\n"], messages);
|
|
|
|
calls.Clear();
|
|
controller = NewController(calls, isAway: true);
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "off"));
|
|
Assert.Equal(["afk:False"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsentAndEmoteCommands_RouteTheirExactActions()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls, acceptsLootPermits: false);
|
|
|
|
Execute(ClientCommandId.Consent, "on");
|
|
Execute(ClientCommandId.Consent, "who");
|
|
Execute(ClientCommandId.Consent, "clear");
|
|
Execute(ClientCommandId.Consent, "remove Alice Example");
|
|
Execute(ClientCommandId.Emote, "waves happily");
|
|
|
|
Assert.Equal(
|
|
[
|
|
"consentmode:True",
|
|
"consentwho",
|
|
"consentclear",
|
|
"consentremove:Alice Example",
|
|
"emote:waves happily",
|
|
],
|
|
calls);
|
|
|
|
void Execute(ClientCommandId id, string arguments) =>
|
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
|
}
|
|
|
|
[Fact]
|
|
public void FriendsCommands_ReadAuthoritativeStateAndSendObjectId()
|
|
{
|
|
var friends = new FriendsState();
|
|
friends.Apply(new FriendsUpdate(
|
|
FriendsUpdateType.Full,
|
|
[
|
|
new FriendEntry(0x50000001u, "Alice", true, false, [], []),
|
|
new FriendEntry(0x50000002u, "Bjørn", false, false, [], []),
|
|
]));
|
|
var calls = new List<string>();
|
|
var messages = new List<string>();
|
|
var controller = NewController(calls, messages: messages, friends: friends);
|
|
|
|
Execute(ClientCommandId.Friends, "online");
|
|
Execute(ClientCommandId.FriendsRemove, "alice");
|
|
Execute(ClientCommandId.FriendsAdd, "Cara");
|
|
Execute(ClientCommandId.Friends, "old");
|
|
|
|
Assert.Contains("Alice (Online)", messages.Single());
|
|
Assert.DoesNotContain("Bjørn", messages.Single());
|
|
Assert.Equal(
|
|
["friendremove:1342177281", "friendadd:Cara", "friendsold"],
|
|
calls);
|
|
|
|
void Execute(ClientCommandId id, string arguments) =>
|
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
|
}
|
|
|
|
[Fact]
|
|
public void SquelchAndFilterCommands_ParseRetailOptions()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls, lastTeller: "Recent Teller");
|
|
|
|
Execute(ClientCommandId.Squelch, "-account Alice");
|
|
Execute(ClientCommandId.Unsquelch, "-reply");
|
|
Execute(ClientCommandId.Squelch, "-Magic Caster Name");
|
|
Execute(ClientCommandId.Filter, "-Combat_Self");
|
|
Execute(ClientCommandId.Unfilter, "-Tell");
|
|
|
|
Assert.Equal(
|
|
[
|
|
"accountsquelch:True:Alice",
|
|
"charsquelch:False:0:Recent Teller:1",
|
|
"charsquelch:True:0:Caster Name:7",
|
|
"globalsquelch:True:22",
|
|
"globalsquelch:False:3",
|
|
],
|
|
calls);
|
|
|
|
void Execute(ClientCommandId id, string arguments) =>
|
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
|
}
|
|
|
|
[Fact]
|
|
public void FillComponents_ClearWorksWithoutVendorAndBuyingRequiresOne()
|
|
{
|
|
var calls = new List<string>();
|
|
var messages = new List<string>();
|
|
var controller = NewController(calls, messages: messages, vendorOpen: false);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "clear"));
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "scarabs 500"));
|
|
|
|
Assert.Equal(["clearcomps"], calls);
|
|
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
|
}
|
|
|
|
// ── CH4 REJECT-review Blocker 2 (2026-08-09) ────────────────────────
|
|
// "@house abandon" must run retail's real two-stage confirmation
|
|
// (DoHouse's abandon branch @0x00580D58 → HouseAbandonDialogCallback_
|
|
// First, function entry @0x00580240 (0x00580E1A is only DoHouse's load
|
|
// site for the callback pointer), whose own stage-2 string site
|
|
// @0x005802D8 opens HouseAbandonDialogCallback_Second @0x0057BE90,
|
|
// the ONLY Event_AbandonHouse() call site @0x0057BF01) before sending
|
|
// 0x021F — previously it sent immediately with no confirmation at all.
|
|
// Citations corrected 2026-08-09, CH4 re-review nit 4.
|
|
|
|
[Fact]
|
|
public void HouseAbandon_BothStagesAccepted_ShowsBothPromptsThenSendsExactlyOnce()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
|
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
|
"houseabandon",
|
|
],
|
|
calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void HouseAbandon_DeclineFirstStage_ShowsOnlyOnePromptAndNeverSends()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls, confirmationResponses: new Queue<bool>([false]));
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
|
],
|
|
calls);
|
|
Assert.DoesNotContain("houseabandon", calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void HouseAbandon_DeclineSecondStage_ShowsBothPromptsAndNeverSends()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls, confirmationResponses: new Queue<bool>([true, false]));
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
|
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
|
],
|
|
calls);
|
|
Assert.DoesNotContain("houseabandon", calls);
|
|
}
|
|
|
|
// ── CH4 REJECT-review SHOULD-FIX 5 (2026-08-09) ─────────────────────
|
|
// "@permit add/remove <multi-word name>" joins every token after the
|
|
// mode word into the name (retail's JoinArgsAsName).
|
|
|
|
[Fact]
|
|
public void Permit_MultiWordName_JoinsTheRemainderIntoOneName()
|
|
{
|
|
var calls = new List<string>();
|
|
var controller = NewController(calls);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "add Aunt Agatha"));
|
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "remove Lord Gnarly Beard"));
|
|
|
|
Assert.Equal(
|
|
["permitadd:Aunt Agatha", "permitremove:Lord Gnarly Beard"],
|
|
calls);
|
|
}
|
|
|
|
// ── CH4 REJECT-review SHOULD-FIX 6 (2026-08-09) ─────────────────────
|
|
// @clist/@on/@off with an unresolvable (but single-token) tag raises
|
|
// retail's WeenieError 0x422 ("That channel doesn't exist.") instead
|
|
// of silently doing nothing.
|
|
|
|
[Fact]
|
|
public void ChannelArgumentCommands_UnknownTag_ShowsWeenieError422WithoutSending()
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors);
|
|
|
|
Execute(ClientCommandId.ListChannel, "nonsense");
|
|
Execute(ClientCommandId.OnChannel, "nonsense");
|
|
Execute(ClientCommandId.OffChannel, "nonsense");
|
|
|
|
Assert.Empty(calls);
|
|
Assert.Equal([0x0422u, 0x0422u, 0x0422u], errors);
|
|
|
|
void Execute(ClientCommandId id, string arguments) =>
|
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ClientCommandId.ListChannel, "fellowship", "clist:2048")]
|
|
[InlineData(ClientCommandId.OnChannel, "admin", "on:2")]
|
|
[InlineData(ClientCommandId.OffChannel, "sentinel", "off:512")]
|
|
// CH4 re-review nit 5 (2026-08-09): pins "@clist allegiance" to
|
|
// RequestChannelList(0x02000000) — retail's real Allegiance channel id
|
|
// (RetailChannelTagTable's "allegiance"/"a"/"ab" entries), distinct
|
|
// from the small legacy-channel ids the rows above cover.
|
|
[InlineData(ClientCommandId.ListChannel, "allegiance", "clist:33554432")]
|
|
public void ChannelArgumentCommands_KnownTag_SendsWithoutError(
|
|
ClientCommandId id, string tag, string expectedCall)
|
|
{
|
|
var calls = new List<string>();
|
|
var errors = new List<uint>();
|
|
var controller = NewController(calls, errors);
|
|
|
|
controller.Execute(new ExecuteClientCommandCmd(id, tag));
|
|
|
|
Assert.Empty(errors);
|
|
Assert.Equal([expectedCall], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownCommandId_FailsAtApplicationBoundary()
|
|
{
|
|
var controller = NewController();
|
|
var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty);
|
|
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
|
|
}
|
|
|
|
// ── CT-B4: retail's @log ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Log_WithAName_ReportsWhereChatIsGoingAndHowToStop()
|
|
{
|
|
// Retail's reply, byte-decoded from the paired binary — Binary Ninja
|
|
// truncates it at "Copying chat to %s. Run command…". Note the TWO
|
|
// spaces after the period; they are retail's.
|
|
var messages = new List<string>();
|
|
var calls = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
calls, messages: messages,
|
|
chatLog: name => new ChatLogResult(true, false, name, null));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, "aclog.txt"));
|
|
|
|
Assert.Contains("log:aclog.txt", calls);
|
|
Assert.Equal(
|
|
"Copying chat to aclog.txt. Run command again with no arguments "
|
|
+ "to turn off logging.",
|
|
Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_WhenTheFileCannotBeOpened_SaysSoRatherThanClaimingSuccess()
|
|
{
|
|
var messages = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
messages: messages,
|
|
chatLog: name => new ChatLogResult(false, false, name, null));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, "C:/nope/x.txt"));
|
|
|
|
Assert.Equal(
|
|
"Failed to redirect to file C:/nope/x.txt!",
|
|
Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_WithNoArgument_ClosesTheOpenLogAndSaysBothLines()
|
|
{
|
|
// CloseLogFile announces the file, then DoSetOutput announces the
|
|
// redirect. Two lines, in that order.
|
|
var messages = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
messages: messages,
|
|
chatLog: _ => new ChatLogResult(false, true, string.Empty, "aclog.txt"));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, ""));
|
|
|
|
Assert.Equal(
|
|
["Chat log aclog.txt closed.", "Chat output now directed only to the screen."],
|
|
messages);
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_WithNoArgumentAndNothingOpen_AsksForAFileName()
|
|
{
|
|
// The same verb with the same arguments says something DIFFERENT
|
|
// depending on whether a log was running — retail branches on
|
|
// CloseLogFile's return value, not on the arguments.
|
|
var messages = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
messages: messages,
|
|
chatLog: _ => new ChatLogResult(false, false, string.Empty, null));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, ""));
|
|
|
|
Assert.Equal(
|
|
"Please specify a file to append chat messages to.",
|
|
Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_StartingASecondLogAnnouncesThatTheFirstEnded()
|
|
{
|
|
var messages = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
messages: messages,
|
|
chatLog: name => new ChatLogResult(true, true, name, "old.txt"));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, "new.txt"));
|
|
|
|
Assert.Equal("Chat log old.txt closed.", messages[0]);
|
|
Assert.StartsWith("Copying chat to new.txt.", messages[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_TakesTheWholeRemainderSoASpacedNameSurvives()
|
|
{
|
|
// Retail JoinArgs the arguments before using them as a filename.
|
|
var calls = new List<string>();
|
|
ClientCommandController ctrl = NewController(
|
|
calls,
|
|
chatLog: name => new ChatLogResult(true, false, name, null));
|
|
|
|
ctrl.Execute(new ExecuteClientCommandCmd(
|
|
ClientCommandId.ChatLogFile, "my chat log.txt"));
|
|
|
|
Assert.Contains("log:my chat log.txt", calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void Log_ResolvesFromTheCatalogWithItsWholeRemainderAsTheArgument()
|
|
{
|
|
// The verb had a help entry since CH4 but no catalog entry, so /log
|
|
// printed help and did nothing. This pins the registration.
|
|
Assert.True(RetailClientCommandCatalog.TryMatch(
|
|
"/log my chat log.txt", out RetailClientCommandCatalog.Match match));
|
|
|
|
Assert.Equal(ClientCommandId.ChatLogFile, match.Command);
|
|
Assert.Equal("my chat log.txt", match.Arguments);
|
|
}
|
|
|
|
private static ClientCommandController NewController(
|
|
List<string>? calls = null,
|
|
List<uint>? errors = null,
|
|
uint? playerBitfield = 0x02000028u,
|
|
List<string>? messages = null,
|
|
bool isAway = false,
|
|
bool acceptsLootPermits = true,
|
|
FriendsState? friends = null,
|
|
SquelchState? squelch = null,
|
|
string? lastTeller = null,
|
|
bool vendorOpen = false,
|
|
// CH4 REJECT-review Blocker 2 (2026-08-09): lets a test drive a
|
|
// specific accept/decline sequence through consecutive
|
|
// ShowConfirmation calls (e.g. house-abandon's two-stage prompt).
|
|
// 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)
|
|
{
|
|
calls ??= [];
|
|
errors ??= [];
|
|
messages ??= [];
|
|
return new ClientCommandController(new ClientCommandController.Bindings(
|
|
() => calls.Add("ls"),
|
|
() => calls.Add("mp"),
|
|
() => calls.Add("pka"),
|
|
() => calls.Add("pla"),
|
|
() => calls.Add("house"),
|
|
() => calls.Add("mansion"),
|
|
() => calls.Add("age"),
|
|
() => calls.Add("birth"),
|
|
() => calls.Add("fps"),
|
|
() => calls.Add("lock"),
|
|
messages.Add,
|
|
errors.Add,
|
|
() => playerBitfield,
|
|
() => "1.2.3",
|
|
() => new Position(
|
|
0xA9B40001u,
|
|
new CellFrame(new System.Numerics.Vector3(1f, 2f, 3f),
|
|
System.Numerics.Quaternion.Identity)),
|
|
() => null,
|
|
(message, completed) =>
|
|
{
|
|
calls.Add($"confirm:{message}");
|
|
bool accepted = confirmationResponses is { Count: > 0 }
|
|
? confirmationResponses.Dequeue()
|
|
: true;
|
|
completed(accepted);
|
|
},
|
|
() => calls.Add("suicide"),
|
|
all => calls.Add("clear:" + all),
|
|
name =>
|
|
{
|
|
calls.Add("log:" + name);
|
|
return chatLog?.Invoke(name)
|
|
?? new AcDream.Core.Chat.ChatLogResult(
|
|
Opened: name.Length > 0,
|
|
Closed: name.Length == 0,
|
|
Name: name,
|
|
ClosedName: "old.txt");
|
|
},
|
|
name => calls.Add("saveui:" + name),
|
|
name => calls.Add("loadui:" + name),
|
|
() => calls.Add("saveautoui"),
|
|
() => calls.Add("loadautoui"),
|
|
() => isAway,
|
|
away => calls.Add("afk:" + away),
|
|
message => calls.Add("afkmsg:" + message),
|
|
() => acceptsLootPermits,
|
|
enabled => calls.Add("consentmode:" + enabled),
|
|
() => calls.Add("consentwho"),
|
|
() => calls.Add("consentclear"),
|
|
name => calls.Add("consentremove:" + name),
|
|
message => calls.Add("emote:" + message),
|
|
friends ?? new FriendsState(),
|
|
name => calls.Add("friendadd:" + name),
|
|
id => calls.Add("friendremove:" + id),
|
|
() => calls.Add("friendsclear"),
|
|
() => calls.Add("friendsold"),
|
|
squelch ?? new SquelchState(),
|
|
(add, id, name, type) => calls.Add($"charsquelch:{add}:{id}:{name}:{type}"),
|
|
(add, name) => calls.Add($"accountsquelch:{add}:{name}"),
|
|
(add, type) => calls.Add($"globalsquelch:{add}:{type}"),
|
|
() => lastTeller,
|
|
() => calls.Add("clearcomps"),
|
|
() => vendorOpen,
|
|
(category, price) => calls.Add($"fillcomps:{category}:{price}"),
|
|
() => calls.Add("pklite"),
|
|
() => false,
|
|
title => calls.Add("title:" + title),
|
|
(optionId, value) => calls.Add($"charoption:{optionId}:{value}"),
|
|
name => calls.Add("permitadd:" + name),
|
|
name => calls.Add("permitremove:" + name),
|
|
houseType => calls.Add("hslist:" + houseType),
|
|
() => calls.Add("index"),
|
|
channelId => calls.Add("clist:" + channelId),
|
|
channelId => calls.Add("on:" + channelId),
|
|
channelId => calls.Add("off:" + channelId),
|
|
() => calls.Add("alh"),
|
|
name => calls.Add("alleginfo:" + name),
|
|
() => calls.Add("houseabandon")));
|
|
}
|
|
}
|