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

@ -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)
{