feat(chat): CT-B4 — @log, and the research block that was a wrong question
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>
This commit is contained in:
parent
89db9a794c
commit
0e0a77c9b1
16 changed files with 831 additions and 18 deletions
|
|
@ -494,6 +494,8 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` |
|
||||
| CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` |
|
||||
| CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat |
|
||||
| CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` |
|
||||
| CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `<Tell:IIDString:…>` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
# Campaign CT — complete chat parity (system + GUI)
|
||||
|
||||
**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two
|
||||
items deliberately not shipped: CT-B3 (word filtering — dropped by user
|
||||
direction, register row CT-2) and CT-B4 (session chat log — research-blocked).
|
||||
**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated.
|
||||
CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong
|
||||
premise (see the slice). One item deliberately not shipped: CT-B3 (word
|
||||
filtering — dropped by user direction, register row CT-2).
|
||||
|
||||
**Carried forward:** multi-frame state media. Retail's unseen-text indicator
|
||||
blinks because its `Normal` state authors SIX image frames; our importer keeps
|
||||
one file per state, so nothing authored can animate. The indicator is
|
||||
state-driven and correct today and gains the blink for free once that
|
||||
capability lands. It is the first known customer, not the only one.
|
||||
**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The
|
||||
importer now keeps the whole authored sequence and `UiMediaSequence` plays it.
|
||||
Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the
|
||||
behaviour as well as enabling it: the indicator blinks three times over three
|
||||
seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient
|
||||
attention-flash, not a badge that stays lit until you scroll down. Register
|
||||
rows CT-3, CT-4.
|
||||
|
||||
**Goal, set by the user 2026-08-21: complete retail parity for the chat
|
||||
system AND the chat GUI.** Not "fix the green name" — that was the symptom
|
||||
|
|
@ -127,9 +130,29 @@ Nothing is user-visible until A4.
|
|||
`TabooTable::CreateCheckString @0x00681570` normalises a candidate before
|
||||
`StringMatchesFilter @0x00681600` compares it, which is how retail catches
|
||||
obfuscated spellings.
|
||||
- **CT-B4** The plain-text session chat log (`ClientSystem::s_pLogFile`). We
|
||||
write none. **Research-blocked**: path and rotation are UNKNOWN and are not
|
||||
in the decomp — needs a live check or a dat/filesystem probe.
|
||||
- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was
|
||||
wrong.** There is no automatic session log to have a path for. Retail's
|
||||
`@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0`
|
||||
takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the
|
||||
`fopen(name, "a+")`, and running it again with no argument closes it. So
|
||||
"path and rotation UNKNOWN" was asking a question the design does not have:
|
||||
the player names the file, and there is no rotation because it appends
|
||||
forever.
|
||||
|
||||
The path question that DOES exist — where a bare name lands — is answered by
|
||||
retail's own help text, which the CH4 help table already carried verbatim
|
||||
without anyone reading it: "a log file named Aclog.txt **in your Asheron's
|
||||
Call directory**". acdream cannot use the install directory (the launcher
|
||||
replaces it atomically on update), so a bare name lands in the client's own
|
||||
log directory. Rooted paths are honoured verbatim. Register row CT-5.
|
||||
|
||||
Landed with it: the verb registered in the catalog (it had a help entry
|
||||
since CH4 but no catalog entry, so `/log` printed help and did nothing),
|
||||
retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded
|
||||
from the paired binary, and the writer attached at OPEN so only text after
|
||||
the command is copied. The line logged is the composed display line with the
|
||||
shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll`
|
||||
— downstream of composition, upstream of glyph layout.
|
||||
|
||||
### Group C — chat GUI
|
||||
|
||||
|
|
@ -166,7 +189,6 @@ Nothing is user-visible until A4.
|
|||
Blocks nothing in Group A, but decides whether other tag shapes exist.
|
||||
- Whether retail's transcript supports text selection distinctly from the
|
||||
entry field (blocks CT-C4's scope).
|
||||
- The chat log file's path and rotation (blocks CT-B4).
|
||||
- Whether a chat-specific sound cue exists — a grep came back empty, which is
|
||||
weak evidence, not proof of absence.
|
||||
|
||||
|
|
|
|||
|
|
@ -382,6 +382,9 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
InvokeClient(b => b.ShowConfirmation(text, callback)),
|
||||
Suicide: () => InvokeClient(static b => b.Suicide()),
|
||||
ClearChat: all => InvokeClient(b => b.ClearChat(all)),
|
||||
SetChatLogFile: name => ReadClient(
|
||||
b => b.SetChatLogFile(name),
|
||||
default(AcDream.Core.Chat.ChatLogResult)),
|
||||
SaveUi: name => InvokeClient(b => b.SaveUi(name)),
|
||||
LoadUi: name => InvokeClient(b => b.LoadUi(name)),
|
||||
SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()),
|
||||
|
|
|
|||
|
|
@ -115,6 +115,16 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly TimeSpan _loginCommandDelay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
|
||||
/// for why this is not the install directory retail names.
|
||||
/// </summary>
|
||||
private readonly string _chatLogDirectory;
|
||||
|
||||
private ChatSessionLog? _chatSessionLog;
|
||||
|
||||
private ChatTranscriptLogWriter? _chatLogWriter;
|
||||
|
||||
public LiveSessionRuntimeFactory(
|
||||
LiveSessionPlayerRuntime player,
|
||||
LiveSessionDomainRuntime domain,
|
||||
|
|
@ -127,7 +137,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
string sessionId = "app",
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
int loginCommandDelayMs = 500,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
string? chatLogDirectory = null)
|
||||
{
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||
|
|
@ -146,6 +157,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
throw new ArgumentOutOfRangeException(
|
||||
nameof(loginCommandDelayMs));
|
||||
}
|
||||
_chatLogDirectory = chatLogDirectory
|
||||
?? AcDream.Platform.ApplicationPathSet.Resolve().LogsDirectory;
|
||||
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
|
||||
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
|
@ -252,6 +265,43 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
connectOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c> file lifecycle
|
||||
/// (<c>ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0</c> /
|
||||
/// <c>CloseLogFile @0x0057ACC0</c>). An empty name closes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The writer attaches to the transcript on OPEN rather than at startup,
|
||||
/// which is what retail's own help promises: "All the information that
|
||||
/// appears in your chat window AFTER you type this command will be copied".
|
||||
/// It detaches on close, so a closed log costs nothing per line.
|
||||
/// <para>
|
||||
/// The line written is the composed display line, because that is what
|
||||
/// retail logs — <c>fprintf @0x00563E5B</c> sits inside
|
||||
/// <c>AddTextToScroll</c>, downstream of composition and upstream of glyph
|
||||
/// layout. <see cref="ChatLog.Append"/> is acdream's equivalent single
|
||||
/// fan-in, and it already owns the timestamp decision the log shares.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private ChatLogResult SetChatLogFile(string name)
|
||||
{
|
||||
ChatSessionLog log = _chatSessionLog ??= new ChatSessionLog(_chatLogDirectory);
|
||||
ChatTranscriptLogWriter writer = _chatLogWriter ??= new ChatTranscriptLogWriter(log);
|
||||
string? closedName = log.CurrentName;
|
||||
|
||||
writer.Detach();
|
||||
bool closed = log.Close();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return new ChatLogResult(Opened: false, closed, string.Empty, closedName);
|
||||
|
||||
bool opened = log.Open(name, out string resolved);
|
||||
if (opened)
|
||||
writer.Attach(_domain.Communication.Chat);
|
||||
|
||||
return new ChatLogResult(opened, closed, resolved, closedName);
|
||||
}
|
||||
|
||||
private LiveSessionResetBindings CreateResetBindings(
|
||||
IRuntimeGenerationResetHost resetHost) => new()
|
||||
{
|
||||
|
|
@ -582,6 +632,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_ui.RetailUi?.ShowConfirmation(message, completed),
|
||||
Suicide: session.SendSuicide,
|
||||
ClearChat: _ => _domain.Communication.Chat.Clear(),
|
||||
SetChatLogFile: SetChatLogFile,
|
||||
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
|
||||
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
|
||||
|
|
|
|||
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal file
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using System;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Copies the chat transcript into retail's <c>@log</c> file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail's log write sits INSIDE <c>ClientSystem::AddTextToScroll</c>
|
||||
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>) — downstream of
|
||||
/// composition and upstream of glyph layout. So the log records the finished
|
||||
/// display line, timestamp included, and does not re-derive one.
|
||||
/// <see cref="ChatLog.Append"/> is acdream's equivalent single fan-in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Attaching happens on OPEN rather than at startup, which is what retail's
|
||||
/// own help promises: "All the information that appears in your chat window
|
||||
/// AFTER you type this command will be copied into a text file."
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ChatTranscriptLogWriter
|
||||
{
|
||||
private readonly ChatSessionLog _log;
|
||||
private ChatLog? _source;
|
||||
|
||||
public ChatTranscriptLogWriter(ChatSessionLog log)
|
||||
=> _log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
|
||||
/// <summary>
|
||||
/// Starts copying <paramref name="source"/>, detaching from whatever was
|
||||
/// attached before. Re-attaching to the same transcript does not double
|
||||
/// up.
|
||||
/// </summary>
|
||||
public void Attach(ChatLog source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
Detach();
|
||||
_source = source;
|
||||
source.EntryAppended += Write;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops copying. Detaches from the instance actually attached to, not
|
||||
/// from whatever is current — a transcript replaced mid-log must not leave
|
||||
/// a handler behind on the old one.
|
||||
/// </summary>
|
||||
public void Detach()
|
||||
{
|
||||
if (_source is null)
|
||||
return;
|
||||
|
||||
_source.EntryAppended -= Write;
|
||||
_source = null;
|
||||
}
|
||||
|
||||
private void Write(ChatEntry entry)
|
||||
{
|
||||
ChatLog? source = _source;
|
||||
if (source is null)
|
||||
return;
|
||||
|
||||
// The SAME gate the window uses, so a log never disagrees with the
|
||||
// transcript it is a copy of.
|
||||
bool stamped = source.DisplayTimestampsSource?.Invoke() == true;
|
||||
|
||||
_log.Write(
|
||||
stamped ? ChatLog.FormatTimestampPrefix(entry.Received) : null,
|
||||
ChatVM.FormatEntry(entry));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.Social;
|
||||
|
|
@ -34,6 +35,7 @@ public sealed class ClientCommandController
|
|||
Action<string, Action<bool>> ShowConfirmation,
|
||||
Action Suicide,
|
||||
Action<bool> ClearChat,
|
||||
Func<string, ChatLogResult> SetChatLogFile,
|
||||
Action<string> SaveUi,
|
||||
Action<string> LoadUi,
|
||||
Action SaveAutoUi,
|
||||
|
|
@ -189,6 +191,9 @@ public sealed class ClientCommandController
|
|||
_bindings.ClearChat(FirstArgument(command.Arguments)
|
||||
.Equals("all", StringComparison.OrdinalIgnoreCase));
|
||||
break;
|
||||
case ClientCommandId.ChatLogFile:
|
||||
ExecuteChatLogFile(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.SaveUi:
|
||||
ExecuteUiProfile(command.Arguments, save: true);
|
||||
break;
|
||||
|
|
@ -407,6 +412,38 @@ public sealed class ClientCommandController
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c> (<c>ClientCommunicationSystem::DoSetOutput
|
||||
/// @0x0057E4F0</c>). One verb does both jobs: a filename opens a log, no
|
||||
/// argument closes the open one. All four replies are retail's own
|
||||
/// strings, byte-decoded from the paired binary because Binary Ninja
|
||||
/// truncates its previews at ~33 characters.
|
||||
/// </summary>
|
||||
private void ExecuteChatLogFile(string arguments)
|
||||
{
|
||||
// Retail JoinArgs the remainder, so the name may contain spaces.
|
||||
string name = arguments.Trim();
|
||||
ChatLogResult result = _bindings.SetChatLogFile(name);
|
||||
|
||||
// CloseLogFile announces itself wherever it is called from, which
|
||||
// includes the open path — starting a second log tells you the first
|
||||
// one ended.
|
||||
if (result.Closed)
|
||||
_bindings.ShowSystemMessage($"Chat log {result.ClosedName} closed.");
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage(result.Closed
|
||||
? "Chat output now directed only to the screen."
|
||||
: "Please specify a file to append chat messages to.");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.ShowSystemMessage(result.Opened
|
||||
? $"Copying chat to {result.Name}. Run command again with no arguments to turn off logging."
|
||||
: $"Failed to redirect to file {result.Name}!");
|
||||
}
|
||||
|
||||
private void ExecuteAway(string arguments)
|
||||
{
|
||||
string first = FirstArgument(arguments);
|
||||
|
|
|
|||
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal file
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c> chat-to-file capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is NOT an automatic session transcript. Retail opens a log only when
|
||||
/// the player asks for one by name — <c>ClientCommunicationSystem::DoSetOutput
|
||||
/// @0x0057E4F0</c> takes a filename, <c>StartCopyOutputToFile @0x0057C8A0</c>
|
||||
/// does the <c>fopen(name, "a+")</c>, and running the command again with no
|
||||
/// argument closes it. The file is APPENDED to, never rotated and never
|
||||
/// truncated, which is what retail's own help promises: "If this file already
|
||||
/// exists, it will add the additional text to the end of it."
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every line goes out as <c>timestamp + text + "\n"</c>
|
||||
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>, inside
|
||||
/// <c>ClientSystem::AddTextToScroll</c>), with the timestamp present only when
|
||||
/// the <c>DisplayTimeStamps</c> option is on — the log and the chat window
|
||||
/// share the one stamp rather than deciding separately.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// File handling only. What a line SAYS is composed upstream, because retail
|
||||
/// logs the finished display line rather than re-deriving one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ChatSessionLog : IDisposable
|
||||
{
|
||||
private readonly string _baseDirectory;
|
||||
private StreamWriter? _writer;
|
||||
|
||||
/// <param name="baseDirectory">
|
||||
/// Where a bare filename lands. Retail says "your Asheron's Call
|
||||
/// directory" — its install directory — which acdream cannot use: the
|
||||
/// launcher replaces the install atomically on update, so a file written
|
||||
/// there is wiped or blocks the update. The client's own log directory is
|
||||
/// the equivalent that survives. Rooted paths are still honoured verbatim,
|
||||
/// as retail's <c>fopen</c> would.
|
||||
/// </param>
|
||||
public ChatSessionLog(string baseDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
|
||||
_baseDirectory = baseDirectory;
|
||||
}
|
||||
|
||||
/// <summary>The name the player asked for, or null when nothing is open.</summary>
|
||||
public string? CurrentName { get; private set; }
|
||||
|
||||
public bool IsOpen => _writer is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Retail appends <c>.txt</c> to an extensionless name
|
||||
/// (<c>PSUtils::get_extension</c> against the empty string, then
|
||||
/// <c>+= ".txt"</c>). A name that already carries ANY extension is left
|
||||
/// alone — "chat.old" stays "chat.old" rather than becoming "chat.old.txt".
|
||||
/// </summary>
|
||||
public static string EnsureExtension(string name)
|
||||
=> Path.GetExtension(name).Length == 0 ? name + ".txt" : name;
|
||||
|
||||
/// <summary>
|
||||
/// Opens <paramref name="name"/> for append, closing any log already open
|
||||
/// first — retail's <c>StartCopyOutputToFile</c> calls <c>CloseLogFile</c>
|
||||
/// before it does anything else.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Whether the file opened. Retail reports failure to the player rather
|
||||
/// than treating it as fatal, so an unwritable path is an ordinary answer
|
||||
/// here and not an exception.
|
||||
/// </returns>
|
||||
public bool Open(string name, out string resolvedName)
|
||||
{
|
||||
resolvedName = string.Empty;
|
||||
Close();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return false;
|
||||
|
||||
resolvedName = EnsureExtension(name.Trim());
|
||||
|
||||
try
|
||||
{
|
||||
string path = Path.IsPathRooted(resolvedName)
|
||||
? resolvedName
|
||||
: Path.Combine(_baseDirectory, resolvedName);
|
||||
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
_writer = new StreamWriter(
|
||||
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||
{
|
||||
// Flushed per line: a chat log's whole point is being readable
|
||||
// while the client is still running, and a crash must not eat
|
||||
// the tail that explains it.
|
||||
AutoFlush = true,
|
||||
};
|
||||
CurrentName = resolvedName;
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
_writer = null;
|
||||
CurrentName = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes the open log, if any.</summary>
|
||||
/// <returns>
|
||||
/// Whether one WAS open. Retail's <c>CloseLogFile</c> returns this and its
|
||||
/// caller uses it to choose between "closed" and "please specify a file".
|
||||
/// </returns>
|
||||
public bool Close()
|
||||
{
|
||||
if (_writer is null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_writer.Dispose();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The line is already gone; failing to flush a closing file is not
|
||||
// something the player can act on.
|
||||
}
|
||||
|
||||
_writer = null;
|
||||
CurrentName = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one transcript line. A no-op when no log is open, so the caller
|
||||
/// can hand every line over unconditionally the way retail does.
|
||||
/// </summary>
|
||||
public void Write(string? timestampPrefix, string? text)
|
||||
{
|
||||
StreamWriter? writer = _writer;
|
||||
if (writer is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
writer.Write(timestampPrefix);
|
||||
writer.Write(text);
|
||||
writer.Write('\n');
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A vanished drive or a full disk stops the log; it must not stop
|
||||
// chat. Retail ignores fprintf's return except to warn about very
|
||||
// long lines.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one <c>@log</c> invocation did, so the caller can print retail's
|
||||
/// replies without reaching into the file handle.
|
||||
/// </summary>
|
||||
/// <param name="Opened">A new log was opened.</param>
|
||||
/// <param name="Closed">
|
||||
/// A log that WAS open got closed. True both when closing is the whole point
|
||||
/// and when opening a second log displaced the first — retail's
|
||||
/// <c>StartCopyOutputToFile</c> closes before it opens, and announces it.
|
||||
/// </param>
|
||||
/// <param name="Name">The resolved name of the new log, extension included.</param>
|
||||
/// <param name="ClosedName">The name of the log that was closed, if any.</param>
|
||||
public readonly record struct ChatLogResult(
|
||||
bool Opened,
|
||||
bool Closed,
|
||||
string Name,
|
||||
string? ClosedName);
|
||||
|
|
@ -24,6 +24,12 @@ public enum ClientCommandId
|
|||
ShowLastCorpseLocation,
|
||||
Die,
|
||||
ClearChat,
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c>: start or stop copying chat to a file.
|
||||
/// <c>ClientCommunicationSystem::DoSetOutput @0x0057E4F0</c>.
|
||||
/// </summary>
|
||||
ChatLogFile,
|
||||
SaveUi,
|
||||
LoadUi,
|
||||
SaveAutoUi,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@ public static class RetailClientCommandCatalog
|
|||
"/clear [all]",
|
||||
"/clear [all] - Clears the current chat window, or every chat window.");
|
||||
|
||||
/// <summary>
|
||||
/// Retail takes the whole remainder as the filename — <c>DoSetOutput</c>
|
||||
/// calls <c>JoinArgs</c> first, so a name with spaces in it works.
|
||||
/// </summary>
|
||||
private static readonly Definition ChatLogFile = AnyArguments(
|
||||
ClientCommandId.ChatLogFile,
|
||||
"/log [filename]",
|
||||
"/log [filename] - Echoes chat text to a logfile, or stops if already logging.");
|
||||
|
||||
private static readonly Definition SaveUi = AnyArguments(
|
||||
ClientCommandId.SaveUi,
|
||||
"/saveui [filename]",
|
||||
|
|
@ -497,6 +506,7 @@ public static class RetailClientCommandCatalog
|
|||
["cor"] = Corpse,
|
||||
["die"] = Die,
|
||||
["clear"] = Clear,
|
||||
["log"] = ChatLogFile,
|
||||
["saveui"] = SaveUi,
|
||||
["loadui"] = LoadUi,
|
||||
["saveautoui"] = SaveAutoUi,
|
||||
|
|
|
|||
|
|
@ -949,6 +949,12 @@ public static class RetailCommandHelpTable
|
|||
private static readonly FrozenDictionary<string, string> CatalogVerbDetailByVerb =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// CT-B4 (2026-08-21): "log" became a catalog verb, so its retail
|
||||
// help has to be reachable through the CATALOG path too — the
|
||||
// catalog's own one-line summary is acdream-authored, and showing
|
||||
// that in place of retail's text is exactly what this table exists
|
||||
// to prevent.
|
||||
["log"] = Log,
|
||||
["lifestone"] = LifestoneDetail,
|
||||
["lif"] = LifestoneDetail,
|
||||
["ls"] = LifestoneDetail,
|
||||
|
|
|
|||
|
|
@ -837,6 +837,7 @@ public sealed class LiveSessionCommandRouterTests
|
|||
ShowConfirmation: (_, _) => { },
|
||||
Suicide: () => { },
|
||||
ClearChat: _ => { },
|
||||
SetChatLogFile: _ => default,
|
||||
SaveUi: _ => { },
|
||||
LoadUi: _ => { },
|
||||
SaveAutoUi: () => { },
|
||||
|
|
|
|||
121
tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs
Normal file
121
tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// CT-B4: what actually reaches the <c>@log</c> file.
|
||||
/// </summary>
|
||||
public sealed class ChatTranscriptLogWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _directory =
|
||||
Path.Combine(Path.GetTempPath(), "acdream-logwriter-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch (IOException) { /* nothing left to say */ }
|
||||
}
|
||||
|
||||
private string Read() => File.ReadAllText(Path.Combine(_directory, "session.txt"));
|
||||
|
||||
[Fact]
|
||||
public void TheComposedDisplayLineIsLoggedRatherThanTheRawMessage()
|
||||
{
|
||||
// The entry's own Text is just 'hello' — the speaker and the quotes
|
||||
// are composition. Retail's log write sits downstream of that
|
||||
// (fprintf inside AddTextToScroll), so logging entry.Text would give
|
||||
// a file full of bare fragments with no idea who said them.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
var transcript = new ChatLog();
|
||||
var writer = new ChatTranscriptLogWriter(log);
|
||||
|
||||
log.Open("session", out _);
|
||||
writer.Attach(transcript);
|
||||
transcript.OnLocalSpeech("Dww", "hello", 0x02u, false, 0u);
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("Dww says, \"hello\"\n", Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTimestampFollowsTheSameOptionTheWindowUses()
|
||||
{
|
||||
// Retail computes the stamp ONCE and hands the same string to the
|
||||
// window and to the file, so the two can never disagree.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
bool stamps = false;
|
||||
var transcript = new ChatLog { DisplayTimestampsSource = () => stamps };
|
||||
var writer = new ChatTranscriptLogWriter(log);
|
||||
|
||||
log.Open("session", out _);
|
||||
writer.Attach(transcript);
|
||||
transcript.OnLocalSpeech("Dww", "before", 0x02u, false, 0u);
|
||||
stamps = true;
|
||||
transcript.OnLocalSpeech("Dww", "after", 0x02u, false, 0u);
|
||||
log.Close();
|
||||
|
||||
string[] lines = Read().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal("Dww says, \"before\"", lines[0]);
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Dww says, ""after""$", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingIsLoggedBeforeAttachOrAfterDetach()
|
||||
{
|
||||
// Retail's help is explicit that logging starts when you type the
|
||||
// command: only what appears AFTER it is copied.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
var transcript = new ChatLog();
|
||||
var writer = new ChatTranscriptLogWriter(log);
|
||||
|
||||
log.Open("session", out _);
|
||||
transcript.OnLocalSpeech("Dww", "before attach", 0x02u, false, 0u);
|
||||
writer.Attach(transcript);
|
||||
transcript.OnLocalSpeech("Dww", "during", 0x02u, false, 0u);
|
||||
writer.Detach();
|
||||
transcript.OnLocalSpeech("Dww", "after detach", 0x02u, false, 0u);
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("Dww says, \"during\"\n", Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachingTwiceDoesNotWriteEveryLineTwice()
|
||||
{
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
var transcript = new ChatLog();
|
||||
var writer = new ChatTranscriptLogWriter(log);
|
||||
|
||||
log.Open("session", out _);
|
||||
writer.Attach(transcript);
|
||||
writer.Attach(transcript);
|
||||
transcript.OnLocalSpeech("Dww", "once", 0x02u, false, 0u);
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("Dww says, \"once\"\n", Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetachReleasesTheTranscriptItActuallyAttachedTo()
|
||||
{
|
||||
// A session teardown replaces the transcript. Detaching from the
|
||||
// CURRENT one would leave a live handler on the old one, which then
|
||||
// keeps writing into a file the player believes is closed.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
var first = new ChatLog();
|
||||
var second = new ChatLog();
|
||||
var writer = new ChatTranscriptLogWriter(log);
|
||||
|
||||
log.Open("session", out _);
|
||||
writer.Attach(first);
|
||||
writer.Attach(second); // switches transcripts
|
||||
first.OnLocalSpeech("Dww", "stale", 0x02u, false, 0u);
|
||||
second.OnLocalSpeech("Dww", "live", 0x02u, false, 0u);
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("Dww says, \"live\"\n", Read());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
|
@ -432,6 +433,125 @@ public sealed class ClientCommandControllerTests
|
|||
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,
|
||||
|
|
@ -448,7 +568,8 @@ public sealed class ClientCommandControllerTests
|
|||
// 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)
|
||||
Queue<bool>? confirmationResponses = null,
|
||||
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
|
||||
{
|
||||
calls ??= [];
|
||||
errors ??= [];
|
||||
|
|
@ -483,6 +604,16 @@ public sealed class ClientCommandControllerTests
|
|||
},
|
||||
() => 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"),
|
||||
|
|
|
|||
142
tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs
Normal file
142
tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.Core.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>@log</c> file behaviour: append, never rotate, never truncate.
|
||||
/// </summary>
|
||||
public sealed class ChatSessionLogTests : IDisposable
|
||||
{
|
||||
private readonly string _directory =
|
||||
Path.Combine(Path.GetTempPath(), "acdream-chatlog-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch (IOException) { /* the test already told us what it needed to */ }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("aclog", "aclog.txt")]
|
||||
[InlineData("aclog.txt", "aclog.txt")]
|
||||
[InlineData("aclog.log", "aclog.log")]
|
||||
// A name that already carries ANY extension is left alone; retail tests
|
||||
// the extension for emptiness, not for ".txt".
|
||||
[InlineData("chat.old", "chat.old")]
|
||||
public void AnExtensionlessNameGainsDotTxt(string given, string expected)
|
||||
=> Assert.Equal(expected, ChatSessionLog.EnsureExtension(given));
|
||||
|
||||
[Fact]
|
||||
public void LinesLandInTheFileWithTheirTimestampAndANewline()
|
||||
{
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
Assert.True(log.Open("session", out string resolved));
|
||||
Assert.Equal("session.txt", resolved);
|
||||
log.Write("13:05:09 ", "Dww tells you, \"hello\"");
|
||||
log.Write(null, "Welcome to Dereth.");
|
||||
log.Close();
|
||||
|
||||
Assert.Equal(
|
||||
"13:05:09 Dww tells you, \"hello\"\nWelcome to Dereth.\n",
|
||||
File.ReadAllText(Path.Combine(_directory, "session.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReopeningTheSameNameAppendsRatherThanTruncating()
|
||||
{
|
||||
// Retail's own help promises this: "If this file already exists, it
|
||||
// will add the additional text to the end of it." Truncating would
|
||||
// destroy the previous session's log the moment you start a new one.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
log.Open("session", out _);
|
||||
log.Write(null, "first");
|
||||
log.Close();
|
||||
|
||||
log.Open("session", out _);
|
||||
log.Write(null, "second");
|
||||
log.Close();
|
||||
|
||||
Assert.Equal(
|
||||
"first\nsecond\n",
|
||||
File.ReadAllText(Path.Combine(_directory, "session.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningASecondLogClosesTheFirst()
|
||||
{
|
||||
// StartCopyOutputToFile calls CloseLogFile before it does anything
|
||||
// else, so two files can never be open at once.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
log.Open("one", out _);
|
||||
log.Write(null, "to one");
|
||||
Assert.True(log.Open("two", out _));
|
||||
log.Write(null, "to two");
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("to one\n", File.ReadAllText(Path.Combine(_directory, "one.txt")));
|
||||
Assert.Equal("to two\n", File.ReadAllText(Path.Combine(_directory, "two.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseReportsWhetherOneWasOpen()
|
||||
{
|
||||
// The caller picks between two different retail replies on this, so
|
||||
// it is load-bearing rather than informational.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
Assert.False(log.Close());
|
||||
log.Open("session", out _);
|
||||
Assert.True(log.Close());
|
||||
Assert.False(log.Close());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritingWithNoLogOpenIsANoOp()
|
||||
{
|
||||
// The caller hands over every transcript line unconditionally, the way
|
||||
// retail does, so the closed case has to be silent rather than throw.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
log.Write("13:05:09 ", "nobody is listening");
|
||||
|
||||
Assert.False(log.IsOpen);
|
||||
Assert.Null(log.CurrentName);
|
||||
Assert.False(Directory.Exists(_directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnopenableNameReportsFailureInsteadOfThrowing()
|
||||
{
|
||||
// Retail tells the player "Failed to redirect to file %s!" rather than
|
||||
// dying, so a bad name is an ordinary answer here.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
|
||||
// A directory cannot be opened as a file.
|
||||
Directory.CreateDirectory(Path.Combine(_directory, "taken.txt"));
|
||||
|
||||
Assert.False(log.Open("taken.txt", out string resolved));
|
||||
Assert.Equal("taken.txt", resolved);
|
||||
Assert.False(log.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARootedNameIsHonouredVerbatim()
|
||||
{
|
||||
// Retail's fopen takes the string as given; a player who types a full
|
||||
// path means it.
|
||||
using var log = new ChatSessionLog(_directory);
|
||||
string rooted = Path.Combine(_directory, "nested", "elsewhere.txt");
|
||||
|
||||
Assert.True(log.Open(rooted, out string resolved));
|
||||
Assert.Equal(rooted, resolved);
|
||||
log.Write(null, "here");
|
||||
log.Close();
|
||||
|
||||
Assert.Equal("here\n", File.ReadAllText(rooted));
|
||||
}
|
||||
}
|
||||
|
|
@ -415,6 +415,19 @@ public sealed class RetailCommandHelpTableTests
|
|||
entries[0].LogTextType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpForLog_StillReturnsRetailsOwnTextNowThatItIsACatalogVerb()
|
||||
{
|
||||
// Registering a verb in the catalog CHANGES which help table answers
|
||||
// for it. "log" had retail's real help under the passthrough table for
|
||||
// the whole of CH4; adding it to the catalog without moving that text
|
||||
// would have quietly replaced it with acdream's own one-line summary.
|
||||
Assert.True(
|
||||
RetailCommandHelpTable.TryGetCatalogVerbDetailText("log", out string detail));
|
||||
Assert.Equal(RetailCommandHelpTable.Log, detail);
|
||||
Assert.StartsWith("@log <name> - Echoes chat text to a logfile.", detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogLeafVerbCoverage_ExtractedVsConfirmedNullVsUnverified_MatchesConsolidatedReviewCount()
|
||||
{
|
||||
|
|
@ -475,7 +488,9 @@ public sealed class RetailCommandHelpTableTests
|
|||
// unverified to extracted (its real live-construction is now
|
||||
// ported -- see RetailCommandHelpTable.MessageTypesDetail) --
|
||||
// 43/4/0, zero remaining unverified leaf verbs.
|
||||
Assert.Equal(43, extractedCount);
|
||||
// CT-B4 (2026-08-21): "log" joined the catalog, bringing its already-
|
||||
// extracted retail Detail text with it -- 44/4/0.
|
||||
Assert.Equal(44, extractedCount);
|
||||
Assert.Equal(4, confirmedNullCount);
|
||||
Assert.Equal(0, unverifiedCount);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ public sealed class RetailCommandRegistryConformanceTests
|
|||
new(Status.Implemented, "on"),
|
||||
new(Status.Implemented, "off"),
|
||||
new(Status.Implemented, "title"),
|
||||
new(Status.ServerPassthrough, "log"), // TS-69
|
||||
// CT-B4 (2026-08-21): "log" was never a server passthrough. Retail
|
||||
// handles it entirely client-side — DoSetOutput @0x0057E4F0 opens a
|
||||
// file, and nothing goes on the wire. It was classified here as a
|
||||
// passthrough because it had a help entry and no catalog entry, which
|
||||
// is the shape an unimplemented client command has too.
|
||||
new(Status.Implemented, "log"),
|
||||
new(Status.Implemented, "clear"),
|
||||
new(Status.Implemented, "filter"),
|
||||
new(Status.Implemented, "unfilter"),
|
||||
|
|
@ -211,8 +216,12 @@ public sealed class RetailCommandRegistryConformanceTests
|
|||
public void Registry_StatusCountsMatchTheAuditedTotals()
|
||||
{
|
||||
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
|
||||
// CT-B4 moved "log" from ServerPassthrough to Implemented, so these
|
||||
// two totals shift by one against the CH4 audit. The 152 verb total
|
||||
// is unchanged, which is what NoDuplicateVerbsAcrossEntries and the
|
||||
// section counts protect.
|
||||
Assert.Equal(4, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(139, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue