diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md
index a58fa9f4..986c553b 100644
--- a/docs/plans/2026-08-21-contract-tracker-campaign.md
+++ b/docs/plans/2026-08-21-contract-tracker-campaign.md
@@ -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
diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md
index d7326164..413130c2 100644
--- a/docs/plans/2026-08-21-journal-campaign.md
+++ b/docs/plans/2026-08-21-journal-campaign.md
@@ -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
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index b65fdd36..b44ded67 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -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"),
diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
index 8f2b0c62..8f49aff0 100644
--- a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
+++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
@@ -57,11 +57,17 @@ public sealed class JournalContractsPageController
/// testable without waiting for it.
///
/// Builds one row from the authored template.
+ ///
+ /// 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.
+ ///
public sealed record Bindings(
IRuntimeContractView Contracts,
Func Catalog,
Func Now,
- Func TemplateResolver);
+ Func TemplateResolver,
+ Action? 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();
}
+ ///
+ /// Abandons the selected contract — game action 0x0316.
+ ///
+ ///
+ /// Nothing is removed locally. The server replies with a 0x0315
+ /// carrying DeleteContract and the tracker drops the row then, so a
+ /// refusal leaves the quest visibly intact rather than vanishing it
+ /// optimistically and having it reappear.
+ ///
+ public void AbandonSelected()
+ {
+ if (_selectedContractId == 0u)
+ return;
+
+ _bindings.Abandon?.Invoke(_selectedContractId);
+ }
+
/// Points the detail pane at one contract.
public void Select(uint contractId)
{
diff --git a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs
index b7d2bb81..45787285 100644
--- a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs
+++ b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs
@@ -69,7 +69,15 @@ public sealed class JournalNotesPageController
private readonly UiField? _timerHours;
private readonly UiField? _timerMinutes;
private readonly UiText? _pageNumber;
- private readonly UiText? _location;
+
+ ///
+ /// The location readout is authored EDITABLE (0x16), so it builds as
+ /// a — not the 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.
+ ///
+ 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();
}
///
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 454be105..043aab3f 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -324,6 +324,8 @@ public sealed record QuestRuntimeBindings(
AcDream.Runtime.Gameplay.IRuntimeJournalView Journal,
AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands,
Func PlayerCell,
+ /// Sends the abandon-contract action (0x0316).
+ Action AbandonContract,
/// Where the per-character journal file lives.
string JournalDirectory,
/// How a load or save failure reaches the player.
@@ -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,
diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
index 2acd6b42..4ee9d358 100644
--- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
+++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
@@ -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);
+ ///
+ /// Campaign QJ: abandon a tracked contract — game action 0x0316,
+ /// payload one contract id. The server answers with 0x0315 carrying
+ /// DeleteContract, which is why the client does not remove the row
+ /// itself (ACE: GameActionAbandonContract).
+ ///
+ public static byte[] BuildAbandonContract(uint sequence, uint contractId) =>
+ BuildUInt32(sequence, AbandonContractOpcode, contractId);
+
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
BuildUInt32(sequence, RemoveFriendOpcode, friendId);
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 4801149b..c513e699 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -2597,6 +2597,17 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
+ ///
+ /// 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.
+ ///
+ public void SendAbandonContract(uint contractId)
+ {
+ uint seq = NextGameActionSequence();
+ SendGameAction(ClientCommandRequests.BuildAbandonContract(seq, contractId));
+ }
+
/// Query the local character's played time (0x01C2).
public void SendQueryAge()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
index cfd1587e..3884b2a8 100644
--- a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
@@ -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();
+
+ 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();
+
+ 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()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs
new file mode 100644
index 00000000..6f1b1aaa
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs
@@ -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;
+
+///
+/// Campaign QJ slice QJ3: the journal's notes page.
+///
+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;
+
+ /// An outdoor landcell, so Record has coordinates to stamp.
+ 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,
+ };
+
+ ///
+ /// The page, with each child built as the type the REAL layout produces —
+ /// the location is a field, not a text element.
+ ///
+ 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? 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();
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs
new file mode 100644
index 00000000..90cd0d5d
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs
@@ -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;
+
+///
+/// Every element the Journal panel's controllers bind must build as the widget
+/// type they cast it to.
+///
+///
+///
+/// A controller resolves children with FindDescendant(...) as UiText.
+/// When the authored element is editable (0x16) it builds as a
+/// 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.
+///
+///
+/// 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.
+///
+///
+[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();
+ 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);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs
index d029999a..81127b41 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs
@@ -183,3 +183,25 @@ public sealed class ContractTrackerMessagesTests
HashHeader(count: 60000, buckets: 256), Arrival));
}
}
+
+///
+/// Campaign QJ: the outbound abandon action.
+///
+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);
+ }
+}