acdream/src/AcDream.App/UI/Layout/HousePageController.cs
Erik f7aa8e0eb7
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
fix: complete retail parity stability pass
2026-08-28 20:01:39 +02:00

156 lines
7 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the House tab of the retail Map/House panel (<c>gmHouseUI</c>,
/// class id <c>0x10000025</c>) — <see cref="MapHousePanelController"/>'s
/// second page.
///
/// <para>
/// Retail references: <c>gmHouseUI::PostInit @0x004a2710</c> resolves ONE
/// <c>UIElement_ListBox</c> (<c>m_pTextBox = 0x100001e6</c>) and registers
/// four notice handlers for wire opcodes <c>0x0225-0x0228</c>.
/// <c>gmHouseUI::AddHousePanelText @0x004a2810</c> is
/// <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> —
/// live-DAT-confirmed (<c>MapHousePanelSlotProbeTests</c>) as a single
/// <c>UIElement_Text</c> (Type 12) row template at LayoutDesc
/// <c>0x21000025</c> element <c>0x100001e7</c>, no scrollbar authored. The
/// ListBox itself authors ZERO static child rows — the box starts genuinely
/// empty until the first server notice populates it (retail's own
/// <c>PostInit</c> never calls <c>Update</c>/<c>DisplayHouseData</c>).
/// </para>
///
/// <para>
/// <b>Scope — see ISSUES #413 for the full ledger.</b> Batch C
/// (2026-08-17) shipped the mount (this class) and the wire PARSING
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
/// action). The House-tab ownership-text closer session (also 2026-08-17)
/// wired <see cref="Bindings.Lines"/> to <c>RuntimeHouseState</c>; issue
/// #413 later completed all seven <c>Display*</c> builders for both
/// houseless and owned-house snapshots. <see cref="Bindings.PanelLines"/>
/// preserves retail's Normal/RentPaid/RentNotPaid font-palette index while
/// the original string callback remains a compatibility fallback. The
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
/// from a House-tab-open trigger to retail's real login-complete edge (see
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
/// the House tab the data has usually already arrived.
/// </para>
/// </summary>
public sealed class HousePageController
{
public const uint TextBoxId = 0x100001E6u;
public sealed record Bindings(
Func<IReadOnlyList<string>> Lines,
// Fires once when the page transitions to visible. Night-round
// review F2 (2026-08-17): NOT wired to SendHouseQuery any more —
// retail's HouseQuery (0x021E, CM_House::Event_QueryHouse
// @0x006aaa00) is byte-decoded confirmed to fire exactly once at the
// client's login-complete edge (tail-called, unconditionally, from
// CPlayerSystem::InitializePlayer @0x00563570, right after
// AttemptSendLoginCompleteNotification — both guarded by the SAME
// once-per-session player_initialized flag), never from House-tab
// activation; neither gmHouseUI::PostInit nor gmMapUI::PostInit
// sends one on tab-open. See WorldSession.SendHouseQuery's own
// production call sites (the graphical/headless first-entry-
// completion edges) for where it's actually sent now. This hook
// remains available for a genuinely page-shown concern, but no
// current caller wires it.
Action? OnShown = null,
// Batch C House-ownership-text closer (2026-08-17): the ListBox's
// OWN row template (LayoutDesc 0x21000025 element 0x100001E7,
// live-DAT-confirmed by MapHousePanelSlotProbeTests) is resolved
// through the SAME generic (templateLayoutId, templateElementId) ->
// UiElement seam MapPageController.Bindings.TemplateResolver already
// wires for the Map tab's town hotspots — it performs the identical
// LayoutImporter.ImportInfos+Build operation, nothing map-specific
// about it. Without this, UiTemplateListBox.AddItemFromTemplateList
// always returns null (no resolver = no row), so Refresh silently
// produced zero rows regardless of Lines — the gap this session
// closes alongside the text composition itself.
Func<uint, uint, UiElement?>? TemplateResolver = null,
// Issue #413: typed rows preserve retail's HousePanelTextColor
// palette index. The string-only callback remains for compatibility
// with standalone fixtures and older embedding callers.
Func<IReadOnlyList<HousePanelLine>>? PanelLines = null);
private readonly UiTemplateListBox _listBox;
private readonly Bindings _bindings;
private IReadOnlyList<HousePanelLine> _lastLines = Array.Empty<HousePanelLine>();
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
{
_listBox = listBox;
_bindings = bindings;
}
public static HousePageController? Bind(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(page, TextBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] House tab: ListBox 0x{TextBoxId:X8} not found or not a template list box.");
return null;
}
listBox.TemplateResolver = bindings.TemplateResolver;
var controller = new HousePageController(listBox, bindings);
controller.Refresh(controller.CurrentLines());
return controller;
}
/// <summary>Per-frame poll — cheap no-op when the line set hasn't
/// changed (reference-content compare via SequenceEqual, mirroring the
/// other social-panel pages' revision-gated rebuild discipline).</summary>
public void Tick()
{
IReadOnlyList<HousePanelLine> lines = CurrentLines();
if (lines.SequenceEqual(_lastLines)) return;
Refresh(lines);
}
public void OnShown() => _bindings.OnShown?.Invoke();
private IReadOnlyList<HousePanelLine> CurrentLines()
{
if (_bindings.PanelLines is { } styled)
return styled();
IReadOnlyList<string> plain = _bindings.Lines();
if (plain.Count == 0)
return Array.Empty<HousePanelLine>();
var projected = new HousePanelLine[plain.Count];
for (int i = 0; i < plain.Count; i++)
projected[i] = new HousePanelLine(plain[i], HousePanelTextColor.Normal);
return projected;
}
private void Refresh(IReadOnlyList<HousePanelLine> lines)
{
_lastLines = lines;
_listBox.Flush();
foreach (HousePanelLine line in lines)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is UiText text)
{
int colorIndex = (int)line.Color;
Vector4 color = colorIndex >= 0 && colorIndex < text.FontColorPalette.Count
? text.FontColorPalette[colorIndex]
: text.DefaultColor;
text.LinesProvider = () => [new UiText.Line(line.Text, color)];
}
}
}
}