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>
142 lines
4.7 KiB
C#
142 lines
4.7 KiB
C#
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));
|
|
}
|
|
}
|