feat(ui): Map/House panel — slices 2+3, panel shell + Map tab

Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16)
as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3
recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar
button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and
.Toolbar). Combined into one commit because MapHousePanelController.Bind
depends on both MapPageController and HousePageController existing —
splitting them would mean landing dead code first.

Map tab (gmMapUI, MapPageController):
- Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s"
  shape, reusing WorldTimeService.CurrentCalendar (new
  Func<DerethDateTime.Calendar> dependency threaded through
  InteractionRetainedUiDependencies/GameWindow — a stable long-lived
  service, not routed through the deferred-binding machinery Radar's
  per-session state needs). MonthName enum values already match retail
  display text; HourName's "AndHalf" suffix is rewritten to "-and-Half".
- Coordinate math + marker placement reuse RadarCoordinates/
  LandDefs.GidToLcoord verbatim (both already byte-exact ports of
  CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port.
  PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from
  gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the
  coordinate text and hides the player marker, matching
  gmMapUI::Update's else branch.
- 53-town s_rgLocations table ported verbatim into MapLocations.cs.
  Markers built once at bind time via the panel's own RowTemplateResolver
  against m_pMap's authored hotspot-template attrs (0x47/0x48), with
  literal-string tooltips through AuthoredTooltipText/Enabled
  (RetailTooltipPresenter) — closes divergence-register row TS-85's last
  item, gmMapUI::AddMapNote @0x004A1C51.
- Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1
  BUTTON (the GM click-to-teleport hook at
  gmMapUI::ListenToElementMessage), and the player/house icons
  (0x100001ED/EE) are its own NESTED children, not siblings —
  UiButton.ConsumesDatChildren swallows them from the normally-built
  tree. Both are re-resolved standalone through the same template
  resolver the town hotspots use and reattached under m_pMap.

House tab (gmHouseUI, HousePageController): mounts the ListBox
(0x100001E6) with its authored row template, wired to an empty Lines()
source by default — genuinely empty until Slice 4's wire lands, matching
retail's own PostInit (no Update call, no static content).

21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests):
tab table pairing, close button, town-hotspot count/tooltips, calendar
formatter golden values (Frostfell 27/119 P.Y., every HourName incl.
AndHalf), player/house marker placement and indoor-gating reproduced
against the real fixture via already-tested RadarCoordinates (no
re-derivation). Fixture map_house_2100006E_1000018C.json captured via
the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately
NOT regenerated — out of scope for this batch, would touch unrelated
panels' schema drift).

Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live;
one earlier flaky streaming failure unrelated to this change, confirmed
pre-existing on the branch before these commits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 02:04:19 +02:00
parent 04841b6807
commit 6b0fa4ff0d
15 changed files with 5865 additions and 3 deletions

View file

@ -289,6 +289,20 @@ public sealed record SocialRuntimeBindings(
// Trailing/optional per the established compatibility convention.
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
/// wire the House wire groundwork — the panel still mounts and the Map tab
/// still works standalone.
/// </summary>
public sealed record MapHouseRuntimeBindings(
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
Func<uint> PlayerCellId,
Func<CreateObject.ServerPosition?>? HousePosition = null,
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings(
AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options,
SocialRuntimeBindings Social,
MapHouseRuntimeBindings MapHouse,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable
MountDialogFactory();
MountTooltipPresenter();
MountSocialPanel();
MountMapHousePanel();
MountCharacter();
MountPlugins();
MountInventory();
@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController =>
@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
MapHousePanelController?.Tick(deltaSeconds);
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
@ -3256,6 +3274,117 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
}
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
/// <see cref="RetailPanelCatalog.MapHouse"/> id 16. Same import/Build/Bind
/// recipe as <see cref="MountSocialPanel"/>. The House tab's ListBox rows
/// resolve through its own authored template directly via
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> (a FIXED
/// single template, unlike Friends/Squelch/Fellowship's live-roster
/// row families) — the <see cref="Layout.RowTemplateResolver"/> below
/// serves only the Map tab's per-town hotspot template.
/// </summary>
private void MountMapHousePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.MapHousePanelController.HostLayoutId,
Layout.MapHousePanelController.SlotElementId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine("[UI] Map/House panel: LayoutDesc 0x2100006E slot 0x1000018C not found.");
return;
}
// The town-hotspot template (m_pMap's own 0x47/0x48 attrs) is
// resolved once and cached — same "resolve the ElementInfo once,
// Build a fresh UiElement per call" shape RowTemplateResolver uses
// for the social panel's row families.
var hotspotTemplate = new Layout.RowTemplateResolver(
(templateLayoutId, templateElementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId),
info => LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont).Root);
UiElement? ResolveHotspotTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
}
MapHouseRuntimeBindings mh = _bindings.MapHouse;
var callbacks = new Layout.MapHousePanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.MapHouse),
Map: new Layout.MapPageController.Bindings(
CurrentCalendar: mh.CurrentCalendar,
PlayerCellId: mh.PlayerCellId,
HousePosition: mh.HousePosition ?? (static () => null),
TemplateResolver: ResolveHotspotTemplate),
House: new Layout.HousePageController.Bindings(
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
OnShown: mh.HouseShown));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)
controller = Layout.MapHousePanelController.Bind(rootInfo, layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] Map/House panel: required root did not build as UiTabPanel.");
return;
}
controller.ActivateTabs();
MapHousePanelController = controller;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.MapHouse,
Chrome = RetailWindowChrome.NineSlice,
Left = 240f,
Top = 160f,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.MapHouse,
WindowNames.MapHouse,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail Map/House panel from LayoutDesc importer (0x2100006E slot 0x1000018C).");
}
private void MountDialogFactory()
{
if (DialogFactory is not null)