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