The quest log is on screen. Rows come from the live tracker joined to the authored catalog, the Status column runs QT4's port of FillProgressString, and the detail pane shows contact, locations, description and the other timer. Two things measured rather than assumed, each now pinned by an installed-DAT test rather than left to the commit message: The tab pairing is read from the authored 0x2E table, not inferred from x-order — the FA campaign had to correct exactly that mistake, and Contracts turns out to be the authored DEFAULT tab (0x32 = True), so opening on the wrong one would have looked like an empty panel. The open path needed no keybind at all. Toolbar button 0x1000055A authors 0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds since the toolbar was ported — it just had no panel behind it, so clicking it did nothing. Registering slot 25 finished a wiring that was already three-quarters present. The list rebuild is revision-gated while the repeat countdown is not: nothing on the wire changes as a cooldown runs down, so a rebuild-gated timer would freeze on screen, and a per-frame rebuild would reset the player's scroll under them. Both directions have a test. Deliberately inert: the Abandon button (retail's abandon path is a contract-registry command this campaign did not port — authored and visible, but wiring a no-op handler would look responsive and lie), and the Journal notes and Page List tabs, which are their own feature. Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
13 KiB
C#
368 lines
13 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;
|
|
|
|
private static UiText Text(uint id) => new()
|
|
{
|
|
DatElementId = id,
|
|
Width = 200f,
|
|
Height = 18f,
|
|
};
|
|
|
|
/// <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>
|
|
private static UiElement? RowTemplate(uint layoutId, uint elementId)
|
|
{
|
|
if (layoutId != JournalContractsPageController.RowTemplateLayoutId
|
|
|| elementId != JournalContractsPageController.RowTemplateElementId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = new UiPanel { Width = 270f, Height = 16f };
|
|
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 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));
|
|
}
|
|
}
|