fix(journal): the location readout is a FIELD, refresh at the click, and Abandon works

Three fixes from the first connected round.

The location readout is authored EDITABLE (0x16), so it builds as a UiField —
not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved
it as text, got null, and threw every write away in silence: Record reached the
model and reached the FILE, and never reached the screen. That is exactly what
was reported, and it is a whole class of bug, so the sweep that found it is now
a test over every element all three controllers bind.

The handlers mutated the model and left redrawing to the next frame's Tick.
Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update()
instead — at the moment of the click. The deferred version happened to work in
the client and made the behaviour untestable and a frame late; the notes-page
tests I had not written until now fail against it.

Abandon is wired. "Retail's abandon path is a contract-registry command we have
not ported" was wrong — it is game action 0x0316 with a single contract id, and
ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed
locally, so a refusal leaves the quest visibly intact rather than vanishing it
optimistically and having it reappear on the next full table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 16:19:18 +02:00
parent 0b27c5d0fe
commit e35d9386e4
12 changed files with 556 additions and 16 deletions

View file

@ -485,6 +485,55 @@ public sealed class JournalContractsPageControllerTests
Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor);
}
// ── Abandon (game action 0x0316) ────────────────────────────────────
[Fact]
public void AbandonSendsTheSelectedContractAndRemovesNothingLocally()
{
// The row disappears when the SERVER answers with its own 0x0315
// delete. Removing it optimistically would vanish a quest the server
// refused to drop, and it would reappear on the next full table.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
var abandoned = new List<uint>();
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => Catalog(Entry(0x10u, "First")),
Now: () => Now,
TemplateResolver: RowTemplate,
Abandon: abandoned.Add));
controller.AbandonSelected();
Assert.Equal(new[] { 0x10u }, abandoned.ToArray());
Assert.Equal(1, state.View.Snapshot.ContractCount);
}
[Fact]
public void AbandonWithNothingSelectedSendsNothing()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
var abandoned = new List<uint>();
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => ContractCatalog.Empty,
Now: () => Now,
TemplateResolver: RowTemplate,
Abandon: abandoned.Add));
controller.AbandonSelected();
Assert.Empty(abandoned);
}
[Fact]
public void AProgressCounterRendersThroughTheAuthoredFormat()
{

View file

@ -0,0 +1,283 @@
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 QJ3: the journal's notes page.
/// </summary>
public sealed class JournalNotesPageControllerTests
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private const uint PreviousButtonId = 0x10000565u;
private const uint NextButtonId = 0x10000566u;
private const uint NewButtonId = 0x10000567u;
private const uint LabelFieldId = 0x10000569u;
private const uint TitleFieldId = 0x1000056Bu;
private const uint NotesFieldId = 0x1000056Du;
private const uint FirstButtonId = 0x1000056Fu;
private const uint PageNumberId = 0x10000570u;
private const uint LastButtonId = 0x10000571u;
private const uint LocationFieldId = 0x10000573u;
private const uint RecordButtonId = 0x10000574u;
private const uint TimerDaysFieldId = 0x10000576u;
private const uint TimerHoursFieldId = 0x10000578u;
private const uint TimerMinutesFieldId = 0x1000057Au;
private const uint RunningTimerTextId = 0x1000057Cu;
private const uint StartButtonId = 0x1000057Du;
/// <summary>An outdoor landcell, so Record has coordinates to stamp.</summary>
private const uint OutdoorCell = 0xA9B4001Fu;
private static UiField Field(uint id) => new() { ElementId = id, DatElementId = id, Width = 100f, Height = 18f };
private static UiText Text(uint id) => new() { DatElementId = id, Width = 120f, Height = 18f };
private static UiButton Button(uint id) => new(
new ElementInfo { Id = id, Type = 1, Width = 60, Height = 20 },
static _ => (0u, 0, 0))
{
DatElementId = id,
};
/// <summary>
/// The page, with each child built as the type the REAL layout produces —
/// the location is a field, not a text element.
/// </summary>
private static UiElement BuildPage()
{
var page = new UiPanel { Width = 300f, Height = 500f };
foreach (uint id in new[]
{
LabelFieldId, TitleFieldId, NotesFieldId, LocationFieldId,
TimerDaysFieldId, TimerHoursFieldId, TimerMinutesFieldId,
})
{
page.AddChild(Field(id));
}
page.AddChild(Text(PageNumberId));
page.AddChild(Text(RunningTimerTextId));
foreach (uint id in new[]
{
PreviousButtonId, NextButtonId, NewButtonId,
FirstButtonId, LastButtonId, RecordButtonId, StartButtonId,
})
{
page.AddChild(Button(id));
}
return page;
}
private static (JournalNotesPageController Controller, RuntimeJournalState State,
UiElement Page) Bind(Func<DateTime>? now = null, params JournalPage[] pages)
{
var state = new RuntimeJournalState();
state.Load(pages);
UiElement page = BuildPage();
var controller = new JournalNotesPageController(
page,
new JournalNotesPageController.Bindings(
Journal: state.View,
Commands: state,
PlayerCell: () => OutdoorCell,
Now: now ?? (() => Now)));
return (controller, state, page);
}
private static void Click(UiElement page, uint id)
=> (UiElement.FindDescendant(page, id) as UiButton)!.OnClick!();
private static UiField FieldOf(UiElement page, uint id)
=> (UiField)UiElement.FindDescendant(page, id)!;
private static string TextOf(UiElement page, uint id)
{
var text = UiElement.FindDescendant(page, id) as UiText;
return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty;
}
// ── Record ──────────────────────────────────────────────────────────
[Fact]
public void RecordPutsTheLocationOnSCREENAndNotJustInTheModel()
{
// The readout is authored EDITABLE, so it builds as a UiField. Binding
// it as UiText yielded null and threw the write away: the value
// reached the model and the file, and the player saw nothing.
var (_, state, page) = Bind(pages: new JournalPage());
Click(page, RecordButtonId);
Assert.True(state.View.Current.HasLocation);
Assert.NotEqual(string.Empty, FieldOf(page, LocationFieldId).Text);
state.Dispose();
}
[Fact]
public void RecordOnAnEmptyJournalDoesNothing()
{
// No page to record onto. Retail's own guard.
var (_, state, page) = Bind();
Click(page, RecordButtonId);
Assert.Equal(string.Empty, FieldOf(page, LocationFieldId).Text);
state.Dispose();
}
// ── the timer ───────────────────────────────────────────────────────
[Fact]
public void StartingATimerSwapsTheFieldsForTheRunningReadout()
{
// The readout is authored at the SAME x as the three number boxes, so
// both visible at once overlaps illegibly.
var (_, state, page) = Bind(pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Assert.False(FieldOf(page, TimerDaysFieldId).Visible);
Assert.False(FieldOf(page, TimerHoursFieldId).Visible);
Assert.False(FieldOf(page, TimerMinutesFieldId).Visible);
Assert.True(UiElement.FindDescendant(page, RunningTimerTextId)!.Visible);
Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId));
state.Dispose();
}
[Fact]
public void StartWithNoDurationEnteredDoesNothing()
{
// Empty boxes mean no countdown; the strip must stay editable rather
// than swapping to a readout of nothing.
var (_, state, page) = Bind(pages: new JournalPage());
Click(page, StartButtonId);
Assert.True(FieldOf(page, TimerDaysFieldId).Visible);
Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId));
state.Dispose();
}
[Fact]
public void TheRunningTimerCountsDownOnTick()
{
DateTime now = Now;
var (controller, state, page) = Bind(now: () => now, pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId));
now = Now.AddMinutes(30);
controller.Tick();
Assert.Equal("30m 0s", TextOf(page, RunningTimerTextId));
state.Dispose();
}
[Fact]
public void PressingStartAgainStopsTheCountdownAndRestoresTheFields()
{
var (_, state, page) = Bind(pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Click(page, StartButtonId);
Assert.True(FieldOf(page, TimerDaysFieldId).Visible);
Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId));
state.Dispose();
}
// ── navigation ──────────────────────────────────────────────────────
[Fact]
public void NewAppendsAPageAndShowsIt()
{
var (_, state, page) = Bind();
Click(page, NewButtonId);
Assert.Single(state.View.Pages);
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void EveryNavigationCommitsTheCurrentPageFirst()
{
// Retail calls SaveThisPage on the way out of all five navigation
// buttons. Without it, typing and then paging away eats the edit.
var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() });
FieldOf(page, TitleFieldId).SetText("typed but not committed");
Click(page, NextButtonId);
Assert.Equal("typed but not committed", state.View.Pages[0].Title);
state.Dispose();
}
[Fact]
public void FirstAndLastJumpToTheEnds()
{
var (_, state, page) = Bind(
pages: new[] { new JournalPage(), new JournalPage(), new JournalPage() });
Click(page, LastButtonId);
Assert.Equal("~ 3 ~", TextOf(page, PageNumberId));
Click(page, FirstButtonId);
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void PreviousAndNextStopAtTheEndsRatherThanWrapping()
{
var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() });
Click(page, PreviousButtonId); // already on page 1
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
Click(page, NextButtonId);
Click(page, NextButtonId); // already on the last
Assert.Equal("~ 2 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void AnEmptyJournalShowsNoPageNumber()
{
// "~ 0 ~" would name a page that does not exist.
var (_, state, page) = Bind();
Assert.Equal(string.Empty, TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void SwitchingPagesShowsThatPagesText()
{
var (_, state, page) = Bind(pages: new[]
{
new JournalPage(Title: "first", Notes: "one"),
new JournalPage(Title: "second", Notes: "two"),
});
Click(page, NextButtonId);
Assert.Equal("second", FieldOf(page, TitleFieldId).Text);
Assert.Equal("two", FieldOf(page, NotesFieldId).Text);
state.Dispose();
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Every element the Journal panel's controllers bind must build as the widget
/// type they cast it to.
/// </summary>
/// <remarks>
/// <para>
/// A controller resolves children with <c>FindDescendant(...) as UiText</c>.
/// When the authored element is editable (<c>0x16</c>) it builds as a
/// <see cref="UiField"/> instead, the cast yields null, and every later write
/// is silently discarded — the value reaches the model and the file, and never
/// appears on screen.
/// </para>
/// <para>
/// That is exactly what happened to the location readout, which is authored
/// EDITABLE and was being resolved as text. This sweep covers the whole panel
/// so the next one fails here instead of in front of a player.
/// </para>
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelBoundWidgetTypesTests
{
private static readonly (uint Id, Type Expected, string Name)[] Bound =
[
// Contracts page — all read-only.
(0x100005CFu, typeof(UiTemplateListBox), "contracts list"),
(0x100005DFu, typeof(UiText), "contract status value"),
(0x100005E0u, typeof(UiText), "contract contact"),
(0x100005E1u, typeof(UiText), "contract contact location"),
(0x100005E2u, typeof(UiText), "contract quest location"),
(0x100005DEu, typeof(UiText), "contract description"),
(0x100005E3u, typeof(UiText), "contract timed value"),
// Notes page.
(0x10000569u, typeof(UiField), "journal label field"),
(0x1000056Bu, typeof(UiField), "journal title field"),
(0x1000056Du, typeof(UiField), "journal notes field"),
(0x10000570u, typeof(UiText), "journal page number"),
(0x10000573u, typeof(UiField), "journal location readout (AUTHORED EDITABLE)"),
(0x10000576u, typeof(UiField), "journal timer days"),
(0x10000578u, typeof(UiField), "journal timer hours"),
(0x1000057Au, typeof(UiField), "journal timer minutes"),
(0x1000057Cu, typeof(UiText), "journal running timer readout"),
// Page list.
(0x10000583u, typeof(UiTemplateListBox), "page list"),
(0x10000587u, typeof(UiField), "page list search box"),
];
private static ImportedLayout BuildPanel()
{
string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir))
{
datDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
}
if (!Directory.Exists(datDir))
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? root = LayoutImporter.ImportInfos(
adapter,
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
Assert.NotNull(root);
var strings = new DatStringResolver(adapter);
return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve);
}
[Fact]
public void EveryBoundElementBuildsAsTheWidgetItsControllerCastsItTo()
{
ImportedLayout layout = BuildPanel();
var wrong = new List<string>();
foreach ((uint id, Type expected, string name) in Bound)
{
UiElement? element = layout.FindElement(id);
if (element is null)
{
wrong.Add($"{name} (0x{id:X8}) is missing from the layout");
continue;
}
if (!expected.IsInstanceOfType(element))
{
wrong.Add(
$"{name} (0x{id:X8}) built as {element.GetType().Name}, "
+ $"controller expects {expected.Name}");
}
}
Assert.Empty(wrong);
}
}

View file

@ -183,3 +183,25 @@ public sealed class ContractTrackerMessagesTests
HashHeader(count: 60000, buckets: 256), Arrival));
}
}
/// <summary>
/// Campaign QJ: the outbound abandon action.
/// </summary>
public sealed class AbandonContractRequestTests
{
[Fact]
public void TheAbandonPayloadIsTheContractIdAlone()
{
// ACE's GameActionAbandonContract reads exactly one uint32 and nothing
// else; a longer payload desyncs the whole game-action stream.
byte[] frame = ClientCommandRequests.BuildAbandonContract(
sequence: 7u, contractId: 0x1234u);
// 0xF7B1 envelope, sequence, opcode, then the payload.
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4)));
Assert.Equal(0x0316u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(8)));
Assert.Equal(0x1234u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(12)));
Assert.Equal(16, frame.Length);
}
}