feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live

The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.

Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:

Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.

The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.

Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.

DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.

The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.

Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 15:42:05 +02:00
parent 536d17456d
commit c5cc8ae5fc
15 changed files with 1380 additions and 76 deletions

View file

@ -0,0 +1,180 @@
using System;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign QJ slice QJ5: the per-character journal file.
/// </summary>
public sealed class JournalPersistenceTests : IDisposable
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-journal-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* the test already said what it needed to */ }
}
private (RuntimeJournalState Journal, JournalPersistence File, List<string> Reports) New()
{
var journal = new RuntimeJournalState();
var reports = new List<string>();
return (journal, new JournalPersistence(journal, _directory, reports.Add), reports);
}
[Fact]
public void APageWrittenInOneSessionIsThereInTheNext()
{
// The whole point of the feature.
(RuntimeJournalState first, JournalPersistence firstFile, _) = New();
firstFile.Load("Acdream");
first.NewPage();
first.UpdateCurrent("label", "A title", "Some notes.");
Assert.True(firstFile.Save(Now));
first.Dispose();
(RuntimeJournalState second, JournalPersistence secondFile, _) = New();
secondFile.Load("Acdream");
JournalPage page = Assert.Single(second.View.Pages);
Assert.Equal("A title", page.Title);
Assert.Equal("Some notes.", page.Notes);
second.Dispose();
}
[Fact]
public void EachCharacterGetsItsOwnFile()
{
// Sharing one file would show a character another's notes.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
journal.UpdateCurrent("a", "Acdream's page", string.Empty);
file.Save(Now);
file.Load("Someone Else");
Assert.Empty(journal.View.Pages);
journal.Dispose();
}
[Fact]
public void ACharacterWithNoFileLoadsAnEmptyJournalWithoutComplaining()
{
// First-time use is the ordinary case, not an error.
(RuntimeJournalState journal, JournalPersistence file, List<string> reports) = New();
file.Load("Newcomer");
Assert.Empty(journal.View.Pages);
Assert.Empty(reports);
journal.Dispose();
}
[Fact]
public void AMalformedFileReportsAndLeavesTheJournalEmpty()
{
// Half-reading a notebook loses pages, and the next save would then
// write that loss back over the original.
(RuntimeJournalState journal, JournalPersistence file, List<string> reports) = New();
Directory.CreateDirectory(_directory);
File.WriteAllText(
Path.Combine(_directory, JournalFile.FileNameFor("acdream", "Acdream")),
"<TITL> no page marker first\n");
file.Load("Acdream");
Assert.Equal(JournalFile.MalformedFileMessage, Assert.Single(reports));
Assert.Empty(journal.View.Pages);
journal.Dispose();
}
[Fact]
public void SavingIsSkippedWhenNothingChanged()
{
// Retail's save fires on every hide; rewriting an unchanged file on
// every tab switch is pure disk churn.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
Assert.True(file.Save(Now));
Assert.False(file.Save(Now));
journal.Dispose();
}
[Fact]
public void SavingBeforeAnyCharacterIsLoadedIsANoOp()
{
// The panel can be disposed before a character ever entered the world.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
Assert.False(file.Save(Now));
Assert.False(Directory.Exists(_directory));
journal.Dispose();
}
[Fact]
public void ARunningTimerPersistsItsREMAININGTime()
{
// Saving the value it started at would resurrect the full duration on
// every reload.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
journal.SetTimer(0, 1, 0);
journal.StartTimer(Now);
file.Save(Now.AddMinutes(30));
journal.Dispose();
(RuntimeJournalState reloaded, JournalPersistence reloadedFile, _) = New();
reloadedFile.Load("Acdream");
Assert.Equal(1800d, Assert.Single(reloaded.View.Pages).RunningTimerSeconds);
reloaded.Dispose();
}
[Fact]
public void CloseSavesAndForgetsTheCharacter()
{
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
file.Close(Now);
Assert.Null(file.CurrentPath);
Assert.False(file.Save(Now));
journal.Dispose();
}
[Fact]
public void AnUnwritableDirectoryReportsRatherThanThrowing()
{
// Losing a note is bad; taking the client down while the player is
// writing one is worse.
var journal = new RuntimeJournalState();
var reports = new List<string>();
string blocked = Path.Combine(_directory, "blocked");
Directory.CreateDirectory(_directory);
File.WriteAllText(blocked, "not a directory");
var file = new JournalPersistence(journal, blocked, reports.Add);
file.Load("Acdream");
journal.NewPage();
Assert.False(file.Save(Now));
Assert.NotEmpty(reports);
journal.Dispose();
}
}

View file

@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QJ slice QJ4: the journal's searchable index
/// (<c>gmPageListUI</c>).
/// </summary>
public sealed class JournalPageListControllerTests
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private const uint ListId = 0x10000583u;
private const uint SearchFieldId = 0x10000587u;
private const uint DeleteButtonId = 0x10000585u;
private const uint RowNumberId = 0x1000058Au;
private const uint RowTitleId = 0x1000058Bu;
// ── the search predicate, ported from PageContainsString ────────────
[Fact]
public void SearchMatchesLabelTitleOrNotes()
{
var page = new JournalPage(Label: "lab", Title: "tit", Notes: "not");
Assert.True(JournalPageListController.PageContainsString(page, "lab"));
Assert.True(JournalPageListController.PageContainsString(page, "tit"));
Assert.True(JournalPageListController.PageContainsString(page, "not"));
Assert.False(JournalPageListController.PageContainsString(page, "zzz"));
}
[Fact]
public void SearchIsCaseSensitiveBecauseRetailUsesWcsstr()
{
// Making it insensitive would be friendlier and would be a divergence.
var page = new JournalPage(Title: "Aerlinthe");
Assert.True(JournalPageListController.PageContainsString(page, "Aer"));
Assert.False(JournalPageListController.PageContainsString(page, "aer"));
}
[Fact]
public void AnEmptySearchMatchesEverything()
{
Assert.True(JournalPageListController.PageContainsString(
new JournalPage(), string.Empty));
}
// ── the list ────────────────────────────────────────────────────────
private static UiText Text(uint id) => new()
{
DatElementId = id,
Width = 90f,
Height = 20f,
DefaultColor = new System.Numerics.Vector4(0.8f, 0.8f, 0.8f, 1f),
};
private static UiElement? RowTemplate(uint layoutId, uint elementId)
{
if (layoutId != JournalPageListController.RowTemplateLayoutId
|| elementId != JournalPageListController.RowTemplateElementId)
{
return null;
}
// A UiDatElement, as the real Type-3 template resolves to.
var row = new UiDatElement(
new ElementInfo { Type = 3, Width = 270, Height = 20 },
static _ => (0u, 0, 0));
row.AddChild(Text(RowNumberId));
row.AddChild(Text(RowTitleId));
return row;
}
private static (UiElement Page, UiField Search) BuildPage()
{
var list = new UiTemplateListBox(
new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 430 },
static _ => (0u, 0, 0),
[new UiTemplateListEntry(
JournalPageListController.RowTemplateLayoutId,
JournalPageListController.RowTemplateElementId)],
scrollbarElementId: 0u)
{
DatElementId = ListId,
};
var search = new UiField { ElementId = SearchFieldId, Width = 118f, Height = 18f };
search.DatElementId = SearchFieldId;
var deleteButton = new UiButton(
new ElementInfo { Id = DeleteButtonId, Type = 1, Width = 60, Height = 18 },
static _ => (0u, 0, 0))
{
DatElementId = DeleteButtonId,
};
var page = new UiPanel { Width = 300f, Height = 500f };
page.AddChild(list);
page.AddChild(search);
page.AddChild(deleteButton);
return (page, search);
}
private static (JournalPageListController Controller, RuntimeJournalState State,
UiElement Page, UiField Search, List<int> Opened) Bind(params JournalPage[] pages)
{
var state = new RuntimeJournalState();
state.Load(pages);
(UiElement page, UiField search) = BuildPage();
var opened = new List<int>();
var controller = new JournalPageListController(
page,
new JournalPageListController.Bindings(
Journal: state.View,
Commands: state,
OpenPage: opened.Add,
TemplateResolver: RowTemplate,
Now: () => Now));
return (controller, state, page, search, opened);
}
[Fact]
public void EveryPageIsListedWithItsNumber()
{
var (controller, state, page, _, _) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
Assert.Equal(new[] { 1, 2 }, controller.RowPages.ToArray());
string[] numbers = Flatten(page)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNumberId)
.Select(t => t.LinesProvider!()[0].Text)
.ToArray();
Assert.Equal(new[] { "1", "2" }, numbers);
state.Dispose();
}
[Fact]
public void SearchingFiltersTheListButKeepsRealPageNumbers()
{
// The row number must name the page in the JOURNAL, not its position
// in the filtered list — otherwise opening row 1 of a filtered list
// opens the wrong page.
var (controller, state, _, search, _) = Bind(
new JournalPage(Title: "alpha"),
new JournalPage(Title: "beta"),
new JournalPage(Title: "gamma"));
search.SetText("beta");
controller.Tick();
Assert.Equal(new[] { 2 }, controller.RowPages.ToArray());
state.Dispose();
}
[Fact]
public void AFilteredOutSelectionIsDroppedSoDeleteCannotHitAHiddenPage()
{
var (controller, state, _, search, _) = Bind(
new JournalPage(Title: "alpha"), new JournalPage(Title: "beta"));
controller.Select(1);
search.SetText("beta");
controller.Tick();
Assert.Equal(0, controller.SelectedPage);
state.Dispose();
}
[Fact]
public void DeleteWithNothingSelectedDoesNothing()
{
var (_, state, page, _, _) = Bind(new JournalPage(Title: "only"));
(UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!();
Assert.Single(state.View.Pages);
state.Dispose();
}
[Fact]
public void DeleteRemovesTheSelectedPage()
{
var (controller, state, page, _, _) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
controller.Select(1);
(UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!();
Assert.Equal("two", Assert.Single(state.View.Pages).Title);
Assert.Equal(0, controller.SelectedPage);
state.Dispose();
}
// ── CheckForDoubleClick ─────────────────────────────────────────────
[Fact]
public void OneClickSelectsAndDoesNotOpen()
{
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
Assert.Equal(1, controller.SelectedPage);
Assert.Empty(opened);
state.Dispose();
}
[Fact]
public void TwoClicksOnTheSameRowOpenIt()
{
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
controller.Click(1);
Assert.Equal(new[] { 1 }, opened.ToArray());
state.Dispose();
}
[Fact]
public void AThirdClickDoesNotReopenBecauseFiringResetsTheTracker()
{
// Retail clears m_LastClickIndex on a successful double-click
// (@0x0049318A). Without that, every click after the second re-opens.
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
controller.Click(1);
controller.Click(1);
Assert.Single(opened);
state.Dispose();
}
[Fact]
public void ClicksOnDifferentRowsAreNotADoubleClick()
{
var (controller, state, _, _, opened) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
controller.Click(1);
controller.Click(2);
Assert.Empty(opened);
Assert.Equal(2, controller.SelectedPage);
state.Dispose();
}
[Fact]
public void TheDoubleClickWindowIsAFullSecond()
{
// Retail's is m_LastClickTime + 1.0 (@0x00493158) — NOT the 500 ms the
// item-interaction path uses. Borrowing the wrong constant makes the
// list feel unresponsive.
var state = new RuntimeJournalState();
state.Load([new JournalPage(Title: "one")]);
(UiElement page, _) = BuildPage();
var opened = new List<int>();
DateTime now = Now;
var controller = new JournalPageListController(
page,
new JournalPageListController.Bindings(
Journal: state.View,
Commands: state,
OpenPage: opened.Add,
TemplateResolver: RowTemplate,
Now: () => now));
controller.Click(1);
now = Now.AddMilliseconds(900);
controller.Click(1);
Assert.Single(opened);
opened.Clear();
now = Now.AddSeconds(10);
controller.Click(1);
now = now.AddMilliseconds(1100);
controller.Click(1);
Assert.Empty(opened);
state.Dispose();
}
private static IEnumerable<UiElement> Flatten(UiElement e)
{
yield return e;
foreach (UiElement child in e.Children)
{
foreach (UiElement descendant in Flatten(child))
yield return descendant;
}
}
}