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

@ -185,9 +185,11 @@ give, use, confirmations) is other features and stays out of Campaign QT.
- The connected user gate: accept a quest against live ACE, open the Journal
panel, confirm the list, the progress column and a repeat countdown.
- The Abandon button is deliberately unwired — retail's abandon path is a
contract-registry command this campaign did not port. It is authored and
visible; clicking it does nothing.
- ~~The Abandon button is deliberately unwired~~ **WIRED 2026-08-21.** The
claim that it had no wire message was wrong: it is game action `0x0316`
carrying one contract id, and ACE answers with the `0x0315` delete QT3
already handles. Nothing is removed locally, so a refused abandon leaves the
quest visibly intact.
- The Journal notes page and Page List tabs mount inert, by design.
## Definition of done

View file

@ -1,7 +1,9 @@
# Campaign QJ — the Journal and Page List tabs
**Status:** CODE-COMPLETE 2026-08-21. All five slices landed; the connected
user gate is owed. Completes the panel Campaign QT mounted: QT
**Status:** CODE-COMPLETE 2026-08-21. All five slices landed, plus the first
connected round's three fixes (button property `0x0D`, the location readout's
widget type, refresh-at-the-click). Abandon is now wired too — it turned out to
have a real wire action after all. The connected re-gate is owed. Completes the panel Campaign QT mounted: QT
shipped the Contracts tab and left the other two inert by design.
**Scope:** retail's `gmJournalUI` (element type `0x10000048`, page

View file

@ -1029,6 +1029,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Journal: d.Runtime.JournalOwner.View,
JournalCommands: d.Runtime.JournalOwner,
PlayerCell: () => d.PlayerController.Controller?.CellId ?? 0u,
AbandonContract: contractId =>
late.Session.CurrentSession?.SendAbandonContract(contractId),
JournalDirectory: System.IO.Path.Combine(
AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory,
"journal"),

View file

@ -57,11 +57,17 @@ public sealed class JournalContractsPageController
/// testable without waiting for it.
/// </param>
/// <param name="TemplateResolver">Builds one row from the authored template.</param>
/// <param name="Abandon">
/// Sends the abandon action for one contract. The row disappears only when
/// the SERVER answers with its own delete, so a refused abandon leaves the
/// quest exactly where it was.
/// </param>
public sealed record Bindings(
IRuntimeContractView Contracts,
Func<ContractCatalog> Catalog,
Func<DateTime> Now,
Func<uint, uint, UiElement?> TemplateResolver);
Func<uint, uint, UiElement?> TemplateResolver,
Action<uint>? Abandon = null);
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _list;
@ -104,11 +110,8 @@ public sealed class JournalContractsPageController
_description = UiElement.FindDescendant(page, DescriptionId) as UiText;
_timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText;
// The Abandon button has no wire message in this campaign's scope —
// retail's own abandon path is a contract-registry command we have not
// ported. Left unwired rather than given a no-op handler that would
// look responsive and do nothing.
_ = AbandonButtonId;
if (UiElement.FindDescendant(page, AbandonButtonId) is UiButton abandon)
abandon.OnClick = AbandonSelected;
Refresh();
}
@ -192,6 +195,23 @@ public sealed class JournalContractsPageController
RefreshDetail();
}
/// <summary>
/// Abandons the selected contract — game action <c>0x0316</c>.
/// </summary>
/// <remarks>
/// Nothing is removed locally. The server replies with a <c>0x0315</c>
/// carrying <c>DeleteContract</c> and the tracker drops the row then, so a
/// refusal leaves the quest visibly intact rather than vanishing it
/// optimistically and having it reappear.
/// </remarks>
public void AbandonSelected()
{
if (_selectedContractId == 0u)
return;
_bindings.Abandon?.Invoke(_selectedContractId);
}
/// <summary>Points the detail pane at one contract.</summary>
public void Select(uint contractId)
{

View file

@ -69,7 +69,15 @@ public sealed class JournalNotesPageController
private readonly UiField? _timerHours;
private readonly UiField? _timerMinutes;
private readonly UiText? _pageNumber;
private readonly UiText? _location;
/// <summary>
/// The location readout is authored EDITABLE (<c>0x16</c>), so it builds as
/// a <see cref="UiField"/> — not the <see cref="UiText"/> its placeholder
/// text suggests. Resolving it as text yielded null and threw every write
/// away silently: Record reached the model and the file, and never the
/// screen.
/// </summary>
private readonly UiField? _location;
private readonly UiText? _runningTimer;
private readonly UiButton? _start;
@ -87,7 +95,7 @@ public sealed class JournalNotesPageController
_timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField;
_timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField;
_pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText;
_location = UiElement.FindDescendant(page, LocationTextId) as UiText;
_location = UiElement.FindDescendant(page, LocationTextId) as UiField;
_runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText;
_start = UiElement.FindDescendant(page, StartButtonId) as UiButton;
@ -108,7 +116,16 @@ public sealed class JournalNotesPageController
field.OnFocusLost = _ => CommitText();
}
Bind(page, NewButtonId, () => { CommitText(); _bindings.Commands.NewPage(); });
// Retail's ListenToElementMessage @0x004968D0 ends every one of these
// in Update() — the panel redraws at the moment of the click, not on
// the next frame. Deferring to Tick would work in the client and makes
// the behaviour untestable and a frame late.
Bind(page, NewButtonId, () =>
{
CommitText();
_bindings.Commands.NewPage();
Refresh();
});
Bind(page, FirstButtonId, () => Navigate(1));
Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount));
Bind(page, PreviousButtonId,
@ -172,7 +189,7 @@ public sealed class JournalNotesPageController
? string.Empty
: $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~");
SetText(_location, page.HasLocation
_location?.SetText(page.HasLocation
? FormatLocation(page.LocationX, page.LocationY)
: string.Empty);
@ -210,6 +227,7 @@ public sealed class JournalNotesPageController
{
CommitText();
_bindings.Commands.GotoPage(pageNumber);
Refresh();
}
private void RecordLocation()
@ -222,6 +240,7 @@ public sealed class JournalNotesPageController
return; // indoors: retail's own gid_to_lcoord failure
_bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y);
Refresh();
}
private void ToggleTimer()
@ -229,11 +248,13 @@ public sealed class JournalNotesPageController
if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d)
{
_bindings.Commands.ResetTimer();
Refresh();
return;
}
CommitText(); // the fields the countdown reads
_bindings.Commands.StartTimer(_bindings.Now());
Refresh();
}
/// <summary>

View file

@ -324,6 +324,8 @@ public sealed record QuestRuntimeBindings(
AcDream.Runtime.Gameplay.IRuntimeJournalView Journal,
AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands,
Func<uint> PlayerCell,
/// <summary>Sends the abandon-contract action (0x0316).</summary>
Action<uint> AbandonContract,
/// <summary>Where the per-character journal file lives.</summary>
string JournalDirectory,
/// <summary>How a load or save failure reaches the player.</summary>
@ -3553,7 +3555,8 @@ public sealed class RetailUiRuntime : IDisposable
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
}),
},
Abandon: _bindings.Quests.AbandonContract),
Notes: new Layout.JournalNotesPageController.Bindings(
Journal: _bindings.Quests.Journal,
Commands: _bindings.Quests.JournalCommands,

View file

@ -25,6 +25,7 @@ public static class ClientCommandRequests
public const uint SetAfkMessageOpcode = 0x0010u;
public const uint EmoteOpcode = 0x01DFu;
public const uint AddFriendOpcode = 0x0018u;
public const uint AbandonContractOpcode = 0x0316u;
public const uint RemoveFriendOpcode = 0x0017u;
public const uint ClearFriendsOpcode = 0x0025u;
public const uint ModifyCharacterSquelchOpcode = 0x0058u;
@ -143,6 +144,15 @@ public static class ClientCommandRequests
public static byte[] BuildAddFriend(uint sequence, string name) =>
BuildString(sequence, AddFriendOpcode, name);
/// <summary>
/// Campaign QJ: abandon a tracked contract — game action <c>0x0316</c>,
/// payload one contract id. The server answers with <c>0x0315</c> carrying
/// <c>DeleteContract</c>, which is why the client does not remove the row
/// itself (ACE: <c>GameActionAbandonContract</c>).
/// </summary>
public static byte[] BuildAbandonContract(uint sequence, uint contractId) =>
BuildUInt32(sequence, AbandonContractOpcode, contractId);
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
BuildUInt32(sequence, RemoveFriendOpcode, friendId);

View file

@ -2597,6 +2597,17 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
/// <summary>
/// Abandon a tracked contract (0x0316). The row disappears when the server
/// answers with its own 0x0315 delete — the client never removes it
/// locally, so a refused abandon leaves the quest exactly where it was.
/// </summary>
public void SendAbandonContract(uint contractId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAbandonContract(seq, contractId));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{

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);
}
}