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:
parent
0b27c5d0fe
commit
e35d9386e4
12 changed files with 556 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue