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>
549 lines
20 KiB
C#
549 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Quests;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign QT slice QT5: the Journal panel's Contracts page.
|
|
/// </summary>
|
|
public sealed class JournalContractsPageControllerTests
|
|
{
|
|
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
// The authored ids, from the installed dats.
|
|
private const uint ListId = 0x100005CFu;
|
|
private const uint RowNameId = 0x100005D1u;
|
|
private const uint RowStatusId = 0x100005D2u;
|
|
private const uint StatusValueId = 0x100005DFu;
|
|
private const uint ContactValueId = 0x100005E0u;
|
|
private const uint ContactLocationValueId = 0x100005E1u;
|
|
private const uint QuestLocationValueId = 0x100005E2u;
|
|
private const uint DescriptionId = 0x100005DEu;
|
|
private const uint TimedValueId = 0x100005E3u;
|
|
|
|
/// <summary>
|
|
/// The authored row name/status colour. Deliberately NOT white: the
|
|
/// selection highlight paints white, so a fixture that starts white cannot
|
|
/// observe the highlight at all.
|
|
/// </summary>
|
|
private static readonly System.Numerics.Vector4 AuthoredTextColor =
|
|
new(0.8f, 0.8f, 0.8f, 1f);
|
|
|
|
private static UiText Text(uint id) => new()
|
|
{
|
|
DatElementId = id,
|
|
Width = 200f,
|
|
Height = 18f,
|
|
DefaultColor = AuthoredTextColor,
|
|
};
|
|
|
|
/// <summary>
|
|
/// A page carrying the same child ids the real one does, so the controller
|
|
/// resolves exactly what production resolves.
|
|
/// </summary>
|
|
private static (UiElement Page, UiTemplateListBox List) BuildPage()
|
|
{
|
|
var listInfo = new ElementInfo
|
|
{
|
|
Id = ListId, Type = 5, Width = 270, Height = 298,
|
|
};
|
|
var list = new UiTemplateListBox(
|
|
listInfo,
|
|
static _ => (0u, 0, 0),
|
|
[new UiTemplateListEntry(
|
|
JournalContractsPageController.RowTemplateLayoutId,
|
|
JournalContractsPageController.RowTemplateElementId)],
|
|
scrollbarElementId: 0u)
|
|
{
|
|
// LayoutImporter.Build sets this in production; a directly
|
|
// constructed widget has it at 0 and FindDescendant never sees it.
|
|
DatElementId = ListId,
|
|
};
|
|
|
|
var page = new UiPanel { Width = 300f, Height = 500f };
|
|
page.AddChild(list);
|
|
foreach (uint id in new[]
|
|
{
|
|
StatusValueId, ContactValueId, ContactLocationValueId,
|
|
QuestLocationValueId, DescriptionId, TimedValueId,
|
|
})
|
|
{
|
|
page.AddChild(Text(id));
|
|
}
|
|
|
|
return (page, list);
|
|
}
|
|
|
|
/// <summary>
|
|
/// One row: a name text and a status text, as retail authors.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The root is a <see cref="UiDatElement"/> because that is what the real
|
|
/// Type-3 template resolves to through DatWidgetFactory's generic fallback
|
|
/// arm — and its click-through default is the entire point of one of the
|
|
/// tests below. A UiPanel here would make that test pass vacuously.
|
|
/// </remarks>
|
|
private static UiElement? RowTemplate(uint layoutId, uint elementId)
|
|
{
|
|
if (layoutId != JournalContractsPageController.RowTemplateLayoutId
|
|
|| elementId != JournalContractsPageController.RowTemplateElementId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = new UiDatElement(
|
|
new ElementInfo
|
|
{
|
|
Id = JournalContractsPageController.RowTemplateElementId,
|
|
Type = 3,
|
|
Width = 270,
|
|
Height = 20,
|
|
},
|
|
static _ => (0u, 0, 0));
|
|
row.AddChild(Text(RowNameId));
|
|
row.AddChild(Text(RowStatusId));
|
|
return row;
|
|
}
|
|
|
|
private static ContractCatalog Catalog(params ContractEntry[] entries)
|
|
=> new(entries.ToDictionary(e => e.ContractId));
|
|
|
|
private static ContractEntry Entry(
|
|
uint id,
|
|
string name,
|
|
string description = "",
|
|
string contact = "",
|
|
string progressFormat = "",
|
|
string repeatFlag = "",
|
|
uint contactCell = 0u,
|
|
uint questCell = 0u)
|
|
=> ContractEntry.Unknown with
|
|
{
|
|
ContractId = id,
|
|
ContractName = name,
|
|
Description = description,
|
|
NameNpcStart = contact,
|
|
DescriptionProgress = progressFormat,
|
|
QuestflagRepeatTime = repeatFlag,
|
|
LocationNpcStartCell = contactCell,
|
|
LocationQuestAreaCell = questCell,
|
|
};
|
|
|
|
private static JournalContractsPageController Bind(
|
|
UiElement page,
|
|
RuntimeContractState state,
|
|
ContractCatalog catalog,
|
|
DateTime? now = null)
|
|
=> new(page, new JournalContractsPageController.Bindings(
|
|
Contracts: state.View,
|
|
Catalog: () => catalog,
|
|
Now: () => now ?? Now,
|
|
TemplateResolver: RowTemplate));
|
|
|
|
private static void Track(
|
|
RuntimeContractState state,
|
|
uint contractId,
|
|
uint stage,
|
|
double whenRepeats = 0d,
|
|
double whenDone = 0d,
|
|
bool setAsDisplay = false)
|
|
=> state.ApplyUpdate(new ContractTrackerUpdate(
|
|
new ContractTracker(1u, contractId, (ContractStage)stage, whenDone, whenRepeats, Now),
|
|
Delete: false,
|
|
SetAsDisplay: setAsDisplay));
|
|
|
|
private static string TextOf(UiElement page, uint id)
|
|
{
|
|
var text = UiElement.FindDescendant(page, id) as UiText;
|
|
return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty;
|
|
}
|
|
|
|
[Fact]
|
|
public void RowsCarryTheAuthoredNameAndTheRetailProgressText()
|
|
{
|
|
// The wire sends only an id and a stage; the name comes from the dat
|
|
// and the status from FillProgressString. Getting either from the
|
|
// wrong source is the failure this pins.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
JournalContractsPageController page1 = Bind(
|
|
page, state, Catalog(Entry(0x10u, "Aerlinthe Recall Ring")));
|
|
|
|
Assert.Equal(new[] { 0x10u }, page1.RowContractIds.ToArray());
|
|
UiText name = Assert.IsType<UiText>(Assert.Single(
|
|
UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten),
|
|
e => (e as UiText)?.DatElementId == RowNameId));
|
|
Assert.Equal("Aerlinthe Recall Ring", name.LinesProvider!()[0].Text);
|
|
}
|
|
|
|
private static IEnumerable<UiElement> Flatten(UiElement e)
|
|
{
|
|
yield return e;
|
|
foreach (UiElement child in e.Children)
|
|
{
|
|
foreach (UiElement descendant in Flatten(child))
|
|
yield return descendant;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheDetailPaneShowsTheSelectedContract()
|
|
{
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
Track(state, 0x20u, stage: 1u);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First", description: "Kill the thing.", contact: "Bob"),
|
|
Entry(0x20u, "Second", description: "Find the other thing.", contact: "Alice")));
|
|
|
|
controller.Select(0x20u);
|
|
|
|
Assert.Equal("Find the other thing.", TextOf(page, DescriptionId));
|
|
Assert.Equal("Alice", TextOf(page, ContactValueId));
|
|
Assert.Equal("Available", TextOf(page, StatusValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheServersDisplayContractIsWhatOpensSelected()
|
|
{
|
|
// SetAsDisplayContract is the server nominating what to show. Ignoring
|
|
// it and always selecting the first row would show the wrong quest
|
|
// right after the one the player just accepted.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
Track(state, 0x20u, stage: 2u, setAsDisplay: true);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First"), Entry(0x20u, "Second")));
|
|
|
|
Assert.Equal(0x20u, controller.SelectedContractId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WithNoDisplayContractTheFirstRowStands()
|
|
{
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x30u, stage: 2u);
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First"), Entry(0x30u, "Third")));
|
|
|
|
// GetContracts orders by id, so 0x10 is first.
|
|
Assert.Equal(0x10u, controller.SelectedContractId);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnEmptyTrackerClearsTheDetailPaneRatherThanStrandingText()
|
|
{
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
JournalContractsPageController controller = Bind(
|
|
page, state, Catalog(Entry(0x10u, "First", description: "Kill it.")));
|
|
Assert.Equal("Kill it.", TextOf(page, DescriptionId));
|
|
|
|
state.ApplyTable(new Dictionary<uint, ContractTracker>());
|
|
controller.Tick();
|
|
|
|
Assert.Equal(0u, controller.SelectedContractId);
|
|
Assert.Equal(string.Empty, TextOf(page, DescriptionId));
|
|
Assert.Empty(controller.RowContractIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheListRebuildsOnlyWhenTheTrackerActuallyMoved()
|
|
{
|
|
// Tick runs every frame while the tracker changes rarely. A rebuild
|
|
// per frame would re-Build a template row list continuously and reset
|
|
// the player's scroll under them.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
int builds = 0;
|
|
|
|
var controller = new JournalContractsPageController(
|
|
page,
|
|
new JournalContractsPageController.Bindings(
|
|
Contracts: state.View,
|
|
Catalog: () => Catalog(Entry(0x10u, "First")),
|
|
Now: () => Now,
|
|
TemplateResolver: (l, e) => { builds++; return RowTemplate(l, e); }));
|
|
|
|
Track(state, 0x10u, stage: 2u);
|
|
controller.Tick();
|
|
int afterFirstChange = builds;
|
|
|
|
for (int frame = 0; frame < 10; frame++)
|
|
controller.Tick();
|
|
|
|
Assert.Equal(afterFirstChange, builds);
|
|
}
|
|
|
|
[Fact]
|
|
public void AContractTheDatHasNeverHeardOfStillGetsARow()
|
|
{
|
|
// The server can track a contract this dat build does not carry. The
|
|
// row must still appear — dropping it would hide a live quest.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0xDEADu, stage: 2u);
|
|
|
|
JournalContractsPageController controller =
|
|
Bind(page, state, ContractCatalog.Empty);
|
|
|
|
Assert.Equal(new[] { 0xDEADu }, controller.RowContractIds.ToArray());
|
|
Assert.Equal("In Progress", TextOf(page, StatusValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void ALocationWithNoOutdoorCoordinatesReadsAsIndoors()
|
|
{
|
|
// Retail's literal string when LandDefs::gid_to_lcoord fails
|
|
// (@0x0049937F). An indoor cell must not render as blank or as raw
|
|
// numbers.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
Bind(page, state, Catalog(Entry(0x10u, "First", contactCell: 0x01020304u)));
|
|
|
|
Assert.Equal("Indoors", TextOf(page, ContactLocationValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUnsetLocationIsBlankRatherThanIndoors()
|
|
{
|
|
// A contract that authors no location at all is different from one
|
|
// whose location is inside a dungeon.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
Bind(page, state, Catalog(Entry(0x10u, "First")));
|
|
|
|
Assert.Equal(string.Empty, TextOf(page, QuestLocationValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheTimedRowUsesTimeWhenDoneNotTimeWhenRepeats()
|
|
{
|
|
// The two wire timers mean different things and land in different
|
|
// places: TimeWhenRepeats drives the Status column, TimeWhenDone this
|
|
// row. Swapping them shows a plausible-looking wrong number.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u, whenDone: 3661d, whenRepeats: 90d);
|
|
|
|
Bind(page, state, Catalog(Entry(0x10u, "First")));
|
|
|
|
Assert.Equal("1h 1m 1s", TextOf(page, TimedValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheRepeatCountdownTicksWithoutRebuildingTheList()
|
|
{
|
|
// Nothing on the wire changes while a cooldown runs down, so a
|
|
// revision-gated rebuild alone would freeze the timer on screen.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 3u, whenRepeats: 600d);
|
|
|
|
DateTime now = Now;
|
|
var controller = new JournalContractsPageController(
|
|
page,
|
|
new JournalContractsPageController.Bindings(
|
|
Contracts: state.View,
|
|
Catalog: () => Catalog(Entry(0x10u, "First", repeatFlag: "f")),
|
|
Now: () => now,
|
|
TemplateResolver: RowTemplate));
|
|
|
|
Assert.Equal("Done (10m 0s to Repeat)", TextOf(page, StatusValueId));
|
|
|
|
now = Now.AddMinutes(5);
|
|
controller.Tick();
|
|
|
|
Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId));
|
|
}
|
|
|
|
[Fact]
|
|
public void RowsOptOutOfClickThroughOrTheListIsDead()
|
|
{
|
|
// The authored row template is a Type-3 generic container, and those
|
|
// are click-THROUGH by default — the click sails past the row to
|
|
// whatever is behind it and the handler never runs. Binding OnClick
|
|
// without clearing ClickThrough compiles, looks right, and produces a
|
|
// list you cannot select anything in.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
|
|
Bind(page, state, Catalog(Entry(0x10u, "First")));
|
|
|
|
UiDatElement row = Assert.IsAssignableFrom<UiDatElement>(Assert.Single(
|
|
UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten),
|
|
e => e is UiDatElement { OnClick: not null }));
|
|
Assert.False(row.ClickThrough);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickingARowSelectsIt()
|
|
{
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
Track(state, 0x20u, stage: 2u);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First"), Entry(0x20u, "Second", description: "Second desc.")));
|
|
Assert.Equal(0x10u, controller.SelectedContractId);
|
|
|
|
UiDatElement second = Assert.IsAssignableFrom<UiDatElement>(
|
|
UiElement.FindDescendant(page, ListId)!.Children
|
|
.SelectMany(Flatten)
|
|
.Where(e => e is UiDatElement { OnClick: not null })
|
|
.ElementAt(1));
|
|
second.OnClick!();
|
|
|
|
Assert.Equal(0x20u, controller.SelectedContractId);
|
|
Assert.Equal("Second desc.", TextOf(page, DescriptionId));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheSelectedRowIsHighlightedAndTheOthersAreNot()
|
|
{
|
|
// Without this the player cannot tell which row the detail pane is
|
|
// describing.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
Track(state, 0x20u, stage: 2u);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First"), Entry(0x20u, "Second")));
|
|
|
|
UiText[] names = UiElement.FindDescendant(page, ListId)!.Children
|
|
.SelectMany(Flatten)
|
|
.OfType<UiText>()
|
|
.Where(t => t.DatElementId == RowNameId)
|
|
.ToArray();
|
|
Assert.Equal(2, names.Length);
|
|
|
|
// Row 0 opens selected (no display contract, so the first row stands).
|
|
System.Numerics.Vector4 selectedColor = names[0].DefaultColor;
|
|
System.Numerics.Vector4 unselectedColor = names[1].DefaultColor;
|
|
Assert.NotEqual(unselectedColor, selectedColor);
|
|
|
|
controller.Select(0x20u);
|
|
|
|
// The highlight MOVES: the old row goes back to its authored colour
|
|
// rather than both rows ending up lit.
|
|
Assert.Equal(unselectedColor, names[0].DefaultColor);
|
|
Assert.Equal(selectedColor, names[1].DefaultColor);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheHighlightSurvivesARebuild()
|
|
{
|
|
// A rebuild discards the row objects, so a remembered selection has to
|
|
// be re-PAINTED onto the new ones rather than merely kept.
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 2u);
|
|
Track(state, 0x20u, stage: 2u);
|
|
|
|
JournalContractsPageController controller = Bind(page, state, Catalog(
|
|
Entry(0x10u, "First"), Entry(0x20u, "Second")));
|
|
controller.Select(0x20u);
|
|
|
|
Track(state, 0x30u, stage: 2u); // forces a rebuild
|
|
controller.Tick();
|
|
|
|
Assert.Equal(0x20u, controller.SelectedContractId);
|
|
UiText selected = UiElement.FindDescendant(page, ListId)!.Children
|
|
.SelectMany(Flatten)
|
|
.OfType<UiText>()
|
|
.Where(t => t.DatElementId == RowNameId)
|
|
.ElementAt(1);
|
|
UiText unselected = UiElement.FindDescendant(page, ListId)!.Children
|
|
.SelectMany(Flatten)
|
|
.OfType<UiText>()
|
|
.Where(t => t.DatElementId == RowNameId)
|
|
.ElementAt(0);
|
|
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()
|
|
{
|
|
(UiElement page, _) = BuildPage();
|
|
using var state = new RuntimeContractState();
|
|
Track(state, 0x10u, stage: 9u); // ProgressCounter + 5
|
|
|
|
Bind(page, state, Catalog(
|
|
Entry(0x10u, "First", progressFormat: "%d/20 Tuskers")));
|
|
|
|
Assert.Equal("5/20 Tuskers", TextOf(page, StatusValueId));
|
|
}
|
|
}
|