diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e03ea0ad..fa5e05d2 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -70,7 +70,8 @@ fill-components. `ClientCommandController` keeps the panel layer independent of App/network services; `WorldSession` sends the exact game-action or control message for server-backed families. Friends and squelch databases are parsed into authoritative Core state, confirmation requests use the imported retail -dialog, and unknown `/` commands still publish `SendServerCommandCmd` in +dialog catalog through the ported context/queue/callback factory, and unknown +`/` commands still publish `SendServerCommandCmd` in canonical `@` form so ACE administrator commands continue to work. Both chat backends use the same router. @@ -83,9 +84,16 @@ showing the live `FPS` and `DEG` values with the authored font and two-decimal format. `/framrate` remains unknown because retail registers no misspelled alias. +The reusable-dialog follow-up ports `DialogFactory @ 0x004773C0..0x00478470` +and type-1 `ConfirmationDialog` rather than sharing one mutable widget. Every +display gets a fresh LayoutDesc `0x2100003C` root; `/die`, server confirmation +tuples (including `CharacterConfirmationDone` aborts), and PK/NPK/volatile-rare +item use now share its property-backed result and lifecycle. + **Research:** `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`; +`docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; `ClientCommunicationSystem::OnChatCommand @ 0x00581320`; `ClientCommunicationSystem::DoLifestone @ 0x0056FC70`; `CM_Character::Event_TeleToLifestone @ 0x006A1B90`. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3b01a7ff..2ef898d4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50). | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | -| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, confirmation dialogs `0x2100003C`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` | +| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7f272db1..0c352709 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -501,7 +501,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. -- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets, and confirmation requests mount imported LayoutDesc `0x2100003C`. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`. +- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index afa1e888..7134e811 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -495,6 +495,10 @@ include dungeons. The first connected gate passed the family except for raw suicide success code `0x004A` and the incomplete title-bar-only FPS toggle; the corrective pass maps retail's success text and mounts the authored SmartBox FPS element. + Confirmation reuse now follows retail's `DialogFactory`: fresh DAT roots, + property-backed results, context ids, independent queues/priority, callbacks, + close notices, and server abort handling; `/die`, gameplay requests, and + guarded item uses share the type-1 presenter. - **L.1b** — Command router + motion-state cleanup (prereq for L.1c). **Freeze on landing:** diff --git a/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md b/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md new file mode 100644 index 00000000..7bb37189 --- /dev/null +++ b/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md @@ -0,0 +1,190 @@ +# Retail dialog factory and confirmation lifecycle + +Date: 2026-07-13 + +## Scope + +This note pins the reusable retail dialog architecture needed by acdream's +confirmation call sites. It ports the factory/context/queue lifecycle and the +type-1 `ConfirmationDialog`. The other six catalog types are identified here +so the factory can accept their presenters later, but their type-specific data +contracts are outside this slice. + +## Named retail sources + +- `Dialog::UpdatePendingDialogDisplay @ 0x00476930` +- `Dialog::UpdateDialogText @ 0x00476A50` +- `Dialog::SetData @ 0x00476BE0` +- `DialogFactory::DialogDone @ 0x004773C0` +- `DialogFactory::UpdatePendingDialogDisplay_ @ 0x00477590` +- `DialogFactory::Reset @ 0x00477950` +- `DialogFactory::CreateDialog_ @ 0x00477AD0` +- `DialogFactory::OpenNextDialog @ 0x00477D30` +- `DialogFactory::MakeDialog @ 0x00477E90` +- `DialogFactory::CloseDialog @ 0x00478160` +- `DialogFactory::MakeCallbackDialogInCurrentUI @ 0x00478430` +- `ConfirmationDialog::SetData @ 0x004764D0` +- `ConfirmationDialog::ListenToElementMessage @ 0x00476670` +- `ConfirmationDialog::CancelDialog @ 0x00476770` +- `ClientCommunicationSystem::DoDie @ 0x00580050` +- `ClientCommunicationSystem::DieDialogCallback @ 0x0057BA70` +- `ClientUISystem::UsageCallback @ 0x00565B20` +- `ClientUISystem::UsageConfirmation_PKAltar @ 0x00566420` +- `ClientUISystem::UsageConfirmation_NPKAltar @ 0x00566610` +- `ClientUISystem::UsageConfirmation_VolatileRare @ 0x00566800` +- `ClientUISystem::Handle_Character__ConfirmationRequest @ 0x005640A0` +- `gmGamePlayUI::MakeGameplayConfirmationDialog @ 0x004EB890` +- `gmGamePlayUI::RecvNotice_CloseDialog @ 0x004EA4F0` +- `gmGamePlayUI::RecvNotice_AbortConfirmationRequest @ 0x004E9D70` + +Verbatim `DialogInfo` layout: `docs/research/named-retail/acclient.h`, struct +5922. Protocol payloads were independently cross-checked against ACE and +ACClientLib during the command-family port; see +`docs/research/2026-07-13-retail-client-command-families-pseudocode.md`. + +## Property contract + +```text +0x8D boolean priority/preempt-current flag +0x8E integer dialog type +0x90 StringInfo optional accept caption +0x91 StringInfo optional reject caption +0x92 boolean confirmation result +0xAC boolean when true, Dialog::SetData sets UIElement attribute 0x40 +0xC3 integer queue key; default 2, value 1 is nonqueued +0xC5 StringInfo main dialog text +0x1000003D instance item/object id carried by item confirmations +``` + +Dialog type to catalog-root/type mapping in `CreateDialog_`: + +```text +1 -> root 0x15, element type 0x13, ConfirmationDialog +2 -> root 0x31, element type 0x19, WaitDialog +3 -> root 0x24, element type 0x17, MessageDialog +4 -> root 0x28, element type 0x18, TextInputDialog +5 -> root 0x2C, element type 0x15, ConfirmationTextInputDialog +6 -> root 0x1B, element type 0x16, MenuDialog +7 -> root 0x1F, element type 0x14, ConfirmationMenuDialog +``` + +## Factory pseudocode + +```text +make_dialog(parent, input_data): + context = ++global_context + info = DialogInfo(copy(input_data), pointer=null, parent, context) + priority = input_data.bool(0x8D, false) + queue_key = input_data.uint(0xC3, 2) + + if queue_key == 1: + append info to active_nonqueued + create_dialog(info, queue_key) + return context + + if no active dialog for queue_key: + create_dialog(info, queue_key) + else if not priority: + pending[queue_key].push_back(info) + update_pending_displays() + else: + current = active[queue_key] + remove current root from parent without completing it + pending[queue_key].push_front(current) + create_dialog(info, queue_key) + + return context + +create_dialog(info, queue_key): + store info as active nonqueued or active[queue_key] + read type from property 0x8E + create a FRESH root element from shared catalog enum (2, 5) + assign context to the Dialog + dialog.SetData(info.data) + bring dialog to front + update_pending_displays() + broadcast OpenDialog(context) + +update_pending_display(dialog, pending_count): + resolve child 0x33 and its text child 0x34 once + child_0x33.SetState(pending_count == 0 ? 0x19 : 0x18) + // retail does not synthesize numeric count text + +close_dialog(context): + if context is active nonqueued: + dialog_done(info) + remove it + return + + if context is an active queued dialog: + remove active[queue_key] + dialog_done(info) + open_next_dialog(queue_key) + return + + if context is pending: + dialog_done(info) + remove it from pending + update_pending_displays() + +dialog_done(info): + if callback exists for info.context: + callback(dialog.data) + remove callback + broadcast CloseDialog(info.context, dialog.data) + remove the root element from its parent + dialog.context = 0 + +open_next_dialog(queue_key): + if pending[queue_key] has an entry: + info = pop_front() + create_dialog(info, queue_key) + delete an empty pending queue +``` + +`Reset` runs `dialog_done` for active and pending dialogs before clearing all +factory collections and callbacks. + +## Confirmation presenter pseudocode + +```text +confirmation.SetData(data): + base.SetData(data) // copies collection; 0xC5 updates child 0x3E + if data has 0x90: set child 0x17 caption + if data has 0x91: set child 0x19 caption + +on button message: + if child is not 0x17 or 0x19: delegate to base + data[0x92] = (child == 0x17) + DialogFactory.CloseDialog(context) + +cancel: + data[0x92] = false + DialogFactory.CloseDialog(context) +``` + +The catalog/root/class are reused; the live widget instance is not. Retail +creates a fresh root for every displayed `DialogInfo`, which is required for +simultaneous nonqueued dialogs and independent queue groups. + +## Integration ownership + +```text +/die: + data = { 0x8E=1, 0xC5=retail message } + MakeCallbackDialogInCurrentUI(data, DieDialogCallback) + callback sends suicide only when 0x92 is true + +item confirmation: + data = { 0x8E=1, 0xC5=message, 0x1000003D=item id } + MakeCallbackDialogInCurrentUI(data, UsageCallback) + callback sends Use only when 0x92 is true + +server gameplay confirmation: + gameplay UI stores server type/context + data = { 0x8E=1, 0xC5=message } + context = MakeDialog(gameplay UI, data) // no direct callback + DialogFactory's CloseDialog notice reaches gmGamePlayUI + gmGamePlayUI reads 0x92 and sends ConfirmationResponse(type, server context, result) + matching ConfirmationDone closes the context through DialogFactory +``` diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0f16d7bc..27424c48 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2208,6 +2208,9 @@ public sealed class GameWindow : IDisposable Cursor: new AcDream.App.UI.RetailUiCursorBindings( cursorFeedbackController, retailCursorManager), + Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings( + (type, context, accepted) => + _liveSession?.SendConfirmationResponse(type, context, accepted)), StackSplitQuantity: _stackSplitQuantity, Plugins: _uiRegistry, Persistence: persistence, @@ -2659,12 +2662,9 @@ public sealed class GameWindow : IDisposable onUseDone: error => _itemInteractionController?.CompleteUse(error), itemMana: ItemMana, onConfirmationRequest: request => - _retailUiRuntime?.ConfirmationDialogs?.Show( - request.Message, - accepted => session.SendConfirmationResponse( - request.Type, - request.ContextId, - accepted)), + _retailUiRuntime?.HandleConfirmationRequest(request), + onConfirmationDone: done => + _retailUiRuntime?.HandleConfirmationDone(done), friends: Friends, squelch: Squelch, onDesiredComponents: components => DesiredComponents = components); @@ -2764,7 +2764,7 @@ public sealed class GameWindow : IDisposable LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu), ShowConfirmation: (message, completed) => - _retailUiRuntime?.ConfirmationDialogs?.Show(message, completed), + _retailUiRuntime?.ShowConfirmation(message, completed), Suicide: liveSession.SendSuicide, ClearChat: _ => chat.Clear(), SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs new file mode 100644 index 00000000..c8b81136 --- /dev/null +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -0,0 +1,91 @@ +using AcDream.App.UI.Layout; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.UI; + +/// +/// Ports the server-driven confirmation ownership in +/// gmGamePlayUI @ 0x004E9D70..0x004E9EB6. The dialog factory broadcasts +/// completion; this semantic owner retains the server type/context and sends the +/// matching confirmation response. +/// +public sealed class GameplayConfirmationController : IDisposable +{ + private readonly RetailDialogFactory _dialogs; + private readonly Action _sendResponse; + private uint _dialogContext; + private uint _serverType; + private uint _serverContext; + private bool _disposed; + + public GameplayConfirmationController( + RetailDialogFactory dialogs, + Action sendResponse) + { + _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs)); + _sendResponse = sendResponse ?? throw new ArgumentNullException(nameof(sendResponse)); + _dialogs.DialogClosed += OnDialogClosed; + } + + public uint ActiveDialogContext => _dialogContext; + + public bool HandleRequest(GameEvents.CharacterConfirmationRequest request) + { + // ClientUISystem::Handle_Character__ConfirmationRequest @ 0x005640A0 + // routes types 2/3/5/6 to handlers that append " Continue?"; the generic + // type-7 YesNo request preserves the server text verbatim. Types 1 and 4 + // have allegiance/fellowship semantic owners but use the same response + // tuple, so this controller retains that tuple until those panels exist. + // Each retail RecvNotice_* handler stores the tuple before calling the + // shared maker, including the already-open case. + _serverType = request.Type; + _serverContext = request.ContextId; + + // gmGamePlayUI::MakeGameplayConfirmationDialog @ 0x004EB890 refuses a + // second gameplay confirmation while its context is non-zero. + if (_dialogContext != 0u) + return false; + + string message = request.Type is 2u or 3u or 5u or 6u + ? request.Message + " Continue?" + : request.Message; + var data = RetailDialogData.Confirmation(message) + .Set(RetailDialogProperty.ElementAttribute40, true); + _dialogContext = _dialogs.MakeDialog(data); + return _dialogContext != 0u; + } + + public bool HandleDone(GameEvents.CharacterConfirmationDone done) + { + // gmGamePlayUI::RecvNotice_AbortConfirmationRequest @ 0x004E9D70 + // matches both server fields before closing the local dialog context. + if (_dialogContext == 0u + || done.Type != _serverType + || done.ContextId != _serverContext) + return false; + return _dialogs.CloseDialog(_dialogContext); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _dialogs.DialogClosed -= OnDialogClosed; + } + + private void OnDialogClosed(uint context, RetailDialogData data) + { + if (context != _dialogContext + || data.GetUInt32(RetailDialogProperty.Type) != + (uint)RetailDialogType.Confirmation) + return; + + bool accepted = data.GetBoolean(RetailDialogProperty.ConfirmationResult); + // gmGamePlayUI::CloseGameplayConfirmationDialog @ 0x004E9E80 sends + // before clearing its retained server/dialog context fields. + _sendResponse(_serverType, _serverContext, accepted); + _dialogContext = 0u; + _serverType = 0u; + _serverContext = 0u; + } +} diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index a52eac19..644fff9d 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -288,6 +288,21 @@ public sealed class ItemInteractionController : IDisposable public bool AcquireSelfTarget() => IsTargetModeActive && AcquireTarget(_playerGuid()); + /// + /// Completes the positive branch of retail + /// ClientUISystem::UsageCallback @ 0x00565B20. Confirmation policy has + /// already consumed the original activation, so acceptance sends the retained + /// object id and enters the same busy state as an ordinary use. + /// + public bool ExecuteConfirmedUse(uint objectId) + { + if (objectId == 0u || _sendUse is null) + return false; + _sendUse(objectId); + _busyCount++; + return true; + } + public void CancelTargetMode() { if (!IsAnyTargetModeActive) return; diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs deleted file mode 100644 index 86d42c46..00000000 --- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace AcDream.App.UI.Layout; - -/// -/// Retained implementation of retail DialogFactory's confirmation queue. -/// The visual tree is element 0x15 from the shared dialog catalog resolved by -/// GetDIDByEnum(2, 5); callbacks mirror property 0x92 from -/// ConfirmationDialog::ListenToElementMessage @ 0x00476670. -/// -public sealed class RetailConfirmationDialogService -{ - public const uint RootElementId = 0x15u; - public const uint PopupElementId = 0x3Du; - public const uint MessageElementId = 0x3Eu; - public const uint AcceptButtonId = 0x17u; - public const uint RejectButtonId = 0x19u; - - private sealed record Request(string Message, Action Completed); - - private readonly UiRoot _host; - private readonly UiDialogRoot _dialog; - private readonly UiElement _popup; - private readonly UiText _message; - private readonly UiButton _accept; - private readonly UiButton _reject; - private readonly Queue _queue = new(); - private readonly float _basePopupHeight; - private readonly float _baseMessageHeight; - private Request? _current; - - public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout) - { - _host = host ?? throw new ArgumentNullException(nameof(host)); - ArgumentNullException.ThrowIfNull(layout); - _dialog = layout.Root as UiDialogRoot - ?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout)); - _popup = layout.FindElement(PopupElementId) - ?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout)); - _message = layout.FindElement(MessageElementId) as UiText - ?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout)); - _accept = layout.FindElement(AcceptButtonId) as UiButton - ?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout)); - _reject = layout.FindElement(RejectButtonId) as UiButton - ?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout)); - - _basePopupHeight = _popup.Height; - _baseMessageHeight = _message.Height; - _popup.LayoutPolicy = null; - _popup.Anchors = AnchorEdges.None; - _message.LayoutPolicy = null; - _message.Anchors = AnchorEdges.None; - _message.Padding = 0f; - _message.Selectable = false; - _dialog.Cancel = () => Complete(false); - _accept.OnClick = () => Complete(true); - _reject.OnClick = () => Complete(false); - } - - public bool IsOpen => _current is not null; - public int PendingCount => _queue.Count; - - public void Show(string message, Action completed) - { - ArgumentNullException.ThrowIfNull(message); - ArgumentNullException.ThrowIfNull(completed); - _queue.Enqueue(new Request(message, completed)); - if (_current is null) - ShowNext(); - } - - public void Tick() - { - if (_current is null) return; - SizeAndCenter(); - } - - private void ShowNext() - { - if (_queue.Count == 0) return; - _current = _queue.Dequeue(); - - float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); - Func measure = _message.DatFont is { } font - ? font.MeasureWidth - : static text => text.Length * 8f; - IReadOnlyList wrapped = UiText.WrapWords( - _current.Message, - measure, - maximumWidth); - var lines = new UiText.Line[wrapped.Count]; - for (int i = 0; i < wrapped.Count; i++) - lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); - _message.LinesProvider = () => lines; - - float lineHeight = _message.DatFont?.LineHeight ?? 16f; - _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); - _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); - - SizeAndCenter(); - _host.AddChild(_dialog); - _host.Modal = _dialog; - } - - private void SizeAndCenter() - { - _dialog.Left = 0f; - _dialog.Top = 0f; - _dialog.Width = _host.Width; - _dialog.Height = _host.Height; - _popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f); - _popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f); - } - - private void Complete(bool accepted) - { - Request? completed = _current; - if (completed is null) return; - _current = null; - _host.RemoveChild(_dialog); - completed.Completed(accepted); - ShowNext(); - } -} diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs new file mode 100644 index 00000000..4707dbb9 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs @@ -0,0 +1,139 @@ +namespace AcDream.App.UI.Layout; + +/// +/// One live type-1 retail confirmation root. Instances are intentionally short-lived: +/// DialogFactory::CreateDialog_ @ 0x00477AD0 creates a fresh catalog root for +/// every displayed DialogInfo. +/// +internal sealed class RetailConfirmationDialogView +{ + public const uint RootElementId = 0x15u; + public const uint AcceptButtonId = 0x17u; + public const uint RejectButtonId = 0x19u; + public const uint PendingDisplayId = 0x33u; + public const uint PendingDisplayTextId = 0x34u; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly RetailDialogData _data; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly UiButton _accept; + private readonly UiButton _reject; + private readonly UiElement? _pendingDisplay; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + + public RetailConfirmationDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + _data = data ?? throw new ArgumentNullException(nameof(data)); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout)); + _accept = layout.FindElement(AcceptButtonId) as UiButton + ?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout)); + _reject = layout.FindElement(RejectButtonId) as UiButton + ?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout)); + _pendingDisplay = layout.FindElement(PendingDisplayId); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + + if (_data.GetString(RetailDialogProperty.AcceptLabel) is { } acceptLabel) + _accept.Label = acceptLabel; + if (_data.GetString(RetailDialogProperty.RejectLabel) is { } rejectLabel) + _reject.Label = rejectLabel; + + Root.Cancel = Reject; + _accept.OnClick = Accept; + _reject.OnClick = Reject; + SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public RetailDialogData Data => _data; + + public void Tick() => SizeAndCenter(); + + public void SetPendingCount(int count) + { + // Dialog::UpdatePendingDialogDisplay @ 0x00476930 resolves children + // 0x33/0x34 and calls SetState(0x19) for an empty queue or SetState(0x18) + // for a non-empty queue. Do not synthesize count text: retail does not. + if (_pendingDisplay is IUiDatStateful stateful) + stateful.TrySetRetailState(count == 0 ? 0x19u : 0x18u); + } + + public void DetachHandlers() + { + Root.Cancel = null; + _accept.OnClick = null; + _reject.OnClick = null; + } + + private void Accept() + { + // ConfirmationDialog::ListenToElementMessage @ 0x00476670. + _data.Set(RetailDialogProperty.ConfirmationResult, true); + _closeDialog(_context); + } + + private void Reject() + { + // ConfirmationDialog::CancelDialog @ 0x00476770 uses the same false + // result for the reject button and Escape/cancel. + _data.Set(RetailDialogProperty.ConfirmationResult, false); + _closeDialog(_context); + } + + private void SetMessage(string text) + { + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + Root.Left = 0f; + Root.Top = 0f; + Root.Width = _host.Width; + Root.Height = _host.Height; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs new file mode 100644 index 00000000..482b9a8c --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -0,0 +1,113 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Property identifiers consumed by retail's Dialog and +/// DialogFactory implementations. +/// +public static class RetailDialogProperty +{ + public const uint Priority = 0x8Du; + public const uint Type = 0x8Eu; + public const uint AcceptLabel = 0x90u; + public const uint RejectLabel = 0x91u; + public const uint ConfirmationResult = 0x92u; + /// + /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean + /// attribute 0x40. The Keystone-owned attribute name is unavailable. + /// + public const uint ElementAttribute40 = 0xACu; + public const uint QueueKey = 0xC3u; + public const uint Message = 0xC5u; + public const uint UsageObjectId = 0x1000003Du; +} + +/// +/// Retail dialog types selected by property 0x8E in +/// DialogFactory::CreateDialog_ @ 0x00477AD0. +/// +public enum RetailDialogType : uint +{ + Confirmation = 1, + Wait = 2, + Message = 3, + TextInput = 4, + ConfirmationTextInput = 5, + Menu = 6, + ConfirmationMenu = 7, +} + +/// +/// Modern typed carrier for retail's PropertyCollection dialog contract. +/// The raw numeric keys remain visible because type-specific presenters and semantic +/// callbacks both extend the same collection in retail. +/// +public sealed class RetailDialogData +{ + private readonly Dictionary _values = new(); + + public IReadOnlyDictionary Values => _values; + + public RetailDialogData Set(uint propertyId, T value) where T : notnull + { + _values[propertyId] = value; + return this; + } + + public bool Contains(uint propertyId) => _values.ContainsKey(propertyId); + + public bool TryGet(uint propertyId, out T value) + { + if (_values.TryGetValue(propertyId, out object? raw) && raw is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + + public bool GetBoolean(uint propertyId, bool defaultValue = false) + => _values.TryGetValue(propertyId, out object? raw) + ? raw switch + { + bool value => value, + byte value => value != 0, + int value => value != 0, + uint value => value != 0, + _ => defaultValue, + } + : defaultValue; + + public uint GetUInt32(uint propertyId, uint defaultValue = 0u) + => _values.TryGetValue(propertyId, out object? raw) + ? raw switch + { + byte value => value, + ushort value => value, + int value when value >= 0 => (uint)value, + uint value => value, + Enum value => Convert.ToUInt32(value), + _ => defaultValue, + } + : defaultValue; + + public string? GetString(uint propertyId) + => _values.TryGetValue(propertyId, out object? raw) ? raw as string : null; + + public RetailDialogData Clone() + { + var clone = new RetailDialogData(); + foreach ((uint propertyId, object value) in _values) + clone._values.Add(propertyId, value); + return clone; + } + + public static RetailDialogData Confirmation(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.Confirmation) + .Set(RetailDialogProperty.Message, message); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs new file mode 100644 index 00000000..2311e278 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -0,0 +1,323 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retained-mode port of retail DialogFactory @ 0x004773C0..0x00478470. +/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs, +/// priority preemption, callback delivery, close notices, and fresh catalog roots. +/// +public sealed class RetailDialogFactory : IDisposable +{ + public const uint DefaultQueueKey = 2u; + public const uint NonQueuedKey = 1u; + + private sealed class DialogInfo + { + public required RetailDialogData Data { get; init; } + public required uint Context { get; init; } + public required uint QueueKey { get; init; } + public Action? Callback { get; init; } + public RetailConfirmationDialogView? View { get; set; } + } + + private readonly UiRoot _host; + private readonly Func _createLayout; + private readonly Dictionary _activeQueued = new(); + private readonly Dictionary _activeNonQueued = new(); + private readonly Dictionary> _pending = new(); + private readonly List _openOrder = new(); + private uint _globalContext; + private bool _disposed; + + public RetailDialogFactory( + UiRoot host, + Func createLayout) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout)); + } + + /// + /// Retail's global close-dialog notice, raised after the per-context callback and + /// before the live root is removed from its parent. + /// + public event Action? DialogClosed; + + public event Action? DialogOpened; + + public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0; + + public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count; + + public int PendingCount => _pending.Values.Sum(static queue => queue.Count); + + /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. + public static uint RootElementId(RetailDialogType type) + => type switch + { + RetailDialogType.Confirmation => 0x15u, + RetailDialogType.Wait => 0x31u, + RetailDialogType.Message => 0x24u, + RetailDialogType.TextInput => 0x28u, + RetailDialogType.ConfirmationTextInput => 0x2Cu, + RetailDialogType.Menu => 0x1Bu, + RetailDialogType.ConfirmationMenu => 0x1Fu, + _ => 0u, + }; + + public uint MakeDialog(RetailDialogData data) + => MakeDialog(data, callback: null); + + /// + /// Retail MakeCallbackDialogInCurrentUI @ 0x00478430. + /// + public uint MakeDialog(RetailDialogData data, Action? callback) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(data); + + uint context = NextContext(); + RetailDialogData ownedData = data.Clone(); + uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey); + if (queueKey == 0u) + queueKey = DefaultQueueKey; + + var info = new DialogInfo + { + Data = ownedData, + Context = context, + QueueKey = queueKey, + Callback = callback, + }; + + if (queueKey == NonQueuedKey) + { + _activeNonQueued.Add(context, info); + CreateDialog(info); + return context; + } + + if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) + { + _activeQueued.Add(queueKey, info); + CreateDialog(info); + return context; + } + + LinkedList queue = PendingQueue(queueKey); + if (!ownedData.GetBoolean(RetailDialogProperty.Priority)) + { + queue.AddLast(info); + UpdatePendingDialogDisplays(); + return context; + } + + // MakeDialog's 0x8D branch removes the active root without completing the + // DialogInfo, inserts that current info at the front, then displays the + // priority request in its place. + Suspend(current); + queue.AddFirst(current); + _activeQueued[queueKey] = info; + CreateDialog(info); + return context; + } + + public uint MakeConfirmation( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey, + bool priority = false) + { + RetailDialogData data = RetailDialogData.Confirmation(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + if (priority) + data.Set(RetailDialogProperty.Priority, true); + return MakeDialog(data, callback); + } + + /// + /// Retail CloseDialog @ 0x00478160. The context can identify an active + /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. + /// + public bool CloseDialog(uint context) + { + if (context == 0u) + return false; + + if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued)) + { + DialogDone(nonQueued); + return true; + } + + foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray()) + { + if (active.Context != context) + continue; + + _activeQueued.Remove(queueKey); + DialogDone(active); + OpenNextDialog(queueKey); + return true; + } + + foreach ((uint queueKey, LinkedList queue) in _pending.ToArray()) + { + LinkedListNode? node = queue.First; + while (node is not null && node.Value.Context != context) + node = node.Next; + if (node is null) + continue; + + DialogInfo pending = node.Value; + queue.Remove(node); + if (queue.Count == 0) + _pending.Remove(queueKey); + DialogDone(pending); + UpdatePendingDialogDisplays(); + return true; + } + + return false; + } + + public void Tick() + { + foreach (DialogInfo info in _openOrder.ToArray()) + info.View?.Tick(); + } + + /// + /// Retail DialogFactory::Reset @ 0x00477950 completes active and pending + /// infos before clearing the factory. + /// + public void Reset() + { + DialogInfo[] infos = _activeNonQueued.Values + .Concat(_activeQueued.Values) + .Concat(_pending.Values.SelectMany(static queue => queue)) + .Distinct() + .ToArray(); + + _activeNonQueued.Clear(); + _activeQueued.Clear(); + _pending.Clear(); + foreach (DialogInfo info in infos) + DialogDone(info); + RefreshModal(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Reset(); + } + + private uint NextContext() + { + _globalContext++; + if (_globalContext == 0u) + _globalContext++; + return _globalContext; + } + + private LinkedList PendingQueue(uint queueKey) + { + if (_pending.TryGetValue(queueKey, out LinkedList? queue)) + return queue; + queue = new LinkedList(); + _pending.Add(queueKey, queue); + return queue; + } + + private void CreateDialog(DialogInfo info) + { + RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); + if (type != RetailDialogType.Confirmation) + throw new NotSupportedException( + $"Retail dialog type {(uint)type} does not have a ported presenter yet."); + + ImportedLayout layout = _createLayout(type) + ?? throw new InvalidOperationException( + $"Retail dialog catalog could not create type {(uint)type}."); + var view = new RetailConfirmationDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)); + info.View = view; + _host.AddChild(view.Root); + _host.BringToFront(view.Root); + _openOrder.Add(info); + _host.Modal = view.Root; + UpdatePendingDialogDisplays(); + DialogOpened?.Invoke(info.Context); + } + + private void Suspend(DialogInfo info) + { + if (info.View is null) + return; + RetailConfirmationDialogView view = info.View; + info.View = null; + view.DetachHandlers(); + _openOrder.Remove(info); + _host.RemoveChild(view.Root); + RefreshModal(); + } + + private void DialogDone(DialogInfo info) + { + // DialogDone @ 0x004773C0 delivers the registered callback first, then + // broadcasts the CloseDialog notice, and only then removes the root. + try + { + info.Callback?.Invoke(info.Data); + DialogClosed?.Invoke(info.Context, info.Data); + } + finally + { + RemoveView(info); + } + } + + private void RemoveView(DialogInfo info) + { + if (info.View is { } view) + { + info.View = null; + view.DetachHandlers(); + _openOrder.Remove(info); + _host.RemoveChild(view.Root); + } + RefreshModal(); + } + + private void OpenNextDialog(uint queueKey) + { + if (!_pending.TryGetValue(queueKey, out LinkedList? queue) + || queue.First is null) + return; + + DialogInfo next = queue.First.Value; + queue.RemoveFirst(); + if (queue.Count == 0) + _pending.Remove(queueKey); + _activeQueued.Add(queueKey, next); + CreateDialog(next); + } + + private void UpdatePendingDialogDisplays() + { + foreach ((uint queueKey, DialogInfo active) in _activeQueued) + { + int count = _pending.TryGetValue(queueKey, out LinkedList? queue) + ? queue.Count + : 0; + active.View?.SetPendingCount(count); + } + } + + private void RefreshModal() + { + _host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root; + } +} diff --git a/src/AcDream.App/UI/RetailItemConfirmationController.cs b/src/AcDream.App/UI/RetailItemConfirmationController.cs new file mode 100644 index 00000000..e16c8134 --- /dev/null +++ b/src/AcDream.App/UI/RetailItemConfirmationController.cs @@ -0,0 +1,65 @@ +using AcDream.App.UI.Layout; +using AcDream.Core.Items; + +namespace AcDream.App.UI; + +/// +/// Retained UI owner for the item-use confirmations emitted by +/// . Message/data construction mirrors +/// ClientUISystem::UsageConfirmation_* @ 0x00566420..0x005669EA and +/// completion mirrors ClientUISystem::UsageCallback @ 0x00565B20. +/// +public sealed class RetailItemConfirmationController : IDisposable +{ + internal const string PlayerKillerMessage = + "Using this altar will make you a player killer, able to attack or be attacked by other player killers. Are you sure you want to do this?"; + internal const string NonPlayerKillerMessage = + "Using this altar will make you a non-player killer, unable to attack or be attacked by other player killers. Are you sure you want to do this?"; + internal const string VolatileRareMessage = + "Are you sure you want to use this rare item?"; + + private readonly RetailDialogFactory _dialogs; + private readonly ItemInteractionController _items; + private bool _disposed; + + public RetailItemConfirmationController( + RetailDialogFactory dialogs, + ItemInteractionController items) + { + _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs)); + _items = items ?? throw new ArgumentNullException(nameof(items)); + _items.PolicyActionRequested += OnPolicyActionRequested; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _items.PolicyActionRequested -= OnPolicyActionRequested; + } + + private void OnPolicyActionRequested(ItemPolicyAction action) + { + string? message = action.Kind switch + { + ItemPolicyActionKind.ConfirmPlayerKillerSwitch => PlayerKillerMessage, + ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch => NonPlayerKillerMessage, + ItemPolicyActionKind.ConfirmVolatileRare => VolatileRareMessage, + _ => null, + }; + if (message is null) + return; + + RetailDialogData data = RetailDialogData.Confirmation(message) + .Set(RetailDialogProperty.UsageObjectId, action.ObjectId); + _dialogs.MakeDialog(data, OnUsageDialogDone); + } + + private void OnUsageDialogDone(RetailDialogData data) + { + if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + return; + uint objectId = data.GetUInt32(RetailDialogProperty.UsageObjectId); + _items.ExecuteConfirmedUse(objectId); + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 10068258..49967cc3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -107,6 +107,9 @@ public sealed record RetailUiCursorBindings( CursorFeedbackController Feedback, RetailCursorManager Manager); +public sealed record ConfirmationRuntimeBindings( + Action SendResponse); + public sealed record RetailUiRuntimeBindings( UiHost Host, RetailUiAssets Assets, @@ -120,6 +123,7 @@ public sealed record RetailUiRuntimeBindings( CharacterRuntimeBindings Character, InventoryRuntimeBindings Inventory, RetailUiCursorBindings Cursor, + ConfirmationRuntimeBindings Confirmations, StackSplitQuantityState StackSplitQuantity, BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, @@ -136,6 +140,8 @@ public sealed class RetailUiRuntime : IDisposable private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity; private readonly RetailWindowLayoutPersistence? _persistence; private readonly RetailUiAutomationScriptRunner? _automation; + private GameplayConfirmationController? _gameplayConfirmationController; + private RetailItemConfirmationController? _itemConfirmationController; private bool _disposed; private RetailUiRuntime(RetailUiRuntimeBindings bindings) @@ -148,7 +154,7 @@ public sealed class RetailUiRuntime : IDisposable MountToolbar(); MountCombat(); MountJumpPowerbar(); - MountConfirmationDialogs(); + MountDialogFactory(); MountCharacter(); MountPlugins(); MountInventory(); @@ -191,7 +197,7 @@ public sealed class RetailUiRuntime : IDisposable public SelectedObjectController? SelectedObjectController { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; } - public RetailConfirmationDialogService? ConfirmationDialogs { get; private set; } + public RetailDialogFactory? DialogFactory { get; private set; } public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -212,7 +218,7 @@ public sealed class RetailUiRuntime : IDisposable FpsController?.Tick(); JumpPowerbarController?.Tick(); SelectedObjectController?.Tick(deltaSeconds); - ConfirmationDialogs?.Tick(); + DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); } @@ -222,6 +228,21 @@ public sealed class RetailUiRuntime : IDisposable public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action) => ToolbarInputController?.Handle(action) == true; + public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request) + => _gameplayConfirmationController?.HandleRequest(request) == true; + + public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done) + => _gameplayConfirmationController?.HandleDone(done) == true; + + public uint ShowConfirmation(string message, Action completed) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(completed); + return DialogFactory?.MakeConfirmation( + message, + data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u; + } + public void UpdateCursor(IEnumerable mice) { CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); @@ -645,34 +666,49 @@ public sealed class RetailUiRuntime : IDisposable Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072."); } - private void MountConfirmationDialogs() + private void MountDialogFactory() { - ImportedLayout? layout; uint layoutId; lock (_bindings.Assets.DatLock) { - // DialogFactory::CreateDialog_ @ 0x00477AD0 calls - // GetDIDByEnum(2, 5), then creates root element 0x15. + // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared + // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then + // creates a fresh type-specific root from that catalog. layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u); - layout = layoutId == 0u - ? null - : LayoutImporter.Import( - _bindings.Assets.Dats, - layoutId, - RetailConfirmationDialogService.RootElementId, - _bindings.Assets.ResolveSprite, - _bindings.Assets.DefaultFont, - _bindings.Assets.ResolveFont); } - if (layout is null) + if (layoutId == 0u) { - Console.WriteLine("[UI] confirmation dialog catalog could not be resolved."); + Console.WriteLine("[UI] retail dialog catalog could not be resolved."); return; } - ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout); - Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15."); + ImportedLayout? CreateLayout(RetailDialogType type) + { + uint rootElementId = RetailDialogFactory.RootElementId(type); + if (rootElementId == 0u) + return null; + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + rootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + } + + DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout); + _gameplayConfirmationController = new GameplayConfirmationController( + DialogFactory, + _bindings.Confirmations.SendResponse); + _itemConfirmationController = new RetailItemConfirmationController( + DialogFactory, + ItemInteraction); + Console.WriteLine( + $"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15."); } private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits() @@ -889,6 +925,9 @@ public sealed class RetailUiRuntime : IDisposable _disposed = true; _persistence?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; + _itemConfirmationController?.Dispose(); + _gameplayConfirmationController?.Dispose(); + DialogFactory?.Dispose(); Host.Dispose(); } } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index b5dc3a70..a37a7c51 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -73,6 +73,7 @@ public static class GameEventWiring Action? onUseDone = null, ItemManaState? itemMana = null, Action? onConfirmationRequest = null, + Action? onConfirmationDone = null, FriendsState? friends = null, SquelchState? squelch = null, Action>? onDesiredComponents = null) @@ -123,6 +124,16 @@ public static class GameEventWiring }); } + if (onConfirmationDone is not null) + { + dispatcher.Register(GameEventType.CharacterConfirmationDone, e => + { + var done = GameEvents.ParseCharacterConfirmationDone(e.Payload.Span); + if (done is not null) + onConfirmationDone(done.Value); + }); + } + if (friends is not null) { dispatcher.Register(GameEventType.FriendsListUpdate, e => diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index c4c00d86..e92890c4 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -505,6 +505,22 @@ public static class GameEvents catch { return null; } } + /// + /// 0x0276 CharacterConfirmationDone — server cancellation/completion of the + /// outstanding confirmation tuple. Retail dispatches the same type/context + /// pair to RecvNotice_AbortConfirmationRequest. + /// + public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId); + + public static CharacterConfirmationDone? ParseCharacterConfirmationDone( + ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new CharacterConfirmationDone( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + } + // ── Shared string reader (matches LoginRequest.ReadString16L) ─────────── private static string ReadString16L(ReadOnlySpan source, ref int pos) diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs new file mode 100644 index 00000000..98da961c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs @@ -0,0 +1,58 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.Tests.UI.Layout; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.UI; + +public sealed class GameplayConfirmationControllerTests +{ + [Fact] + public void SkillRequestAppendsContinueAndCloseNoticeSendsServerTuple() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout? shown = null; + var factory = new RetailDialogFactory(root, _ => + shown = FixtureLoader.LoadConfirmationDialog()); + var responses = new List<(uint Type, uint Context, bool Accepted)>(); + using var controller = new GameplayConfirmationController( + factory, + (type, context, accepted) => responses.Add((type, context, accepted))); + + Assert.True(controller.HandleRequest( + new GameEvents.CharacterConfirmationRequest(2u, 42u, "Raise this skill?"))); + Assert.Equal( + "Raise this skill? Continue?", + string.Join(" ", Assert.IsType(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text))); + + Assert.IsType(shown.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.Equal([(2u, 42u, true)], responses); + Assert.Equal(0u, controller.ActiveDialogContext); + } + + [Fact] + public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var factory = new RetailDialogFactory(root, _ => + FixtureLoader.LoadConfirmationDialog()); + var responses = new List<(uint Type, uint Context, bool Accepted)>(); + using var controller = new GameplayConfirmationController( + factory, + (type, context, accepted) => responses.Add((type, context, accepted))); + controller.HandleRequest(new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?")); + + Assert.False(controller.HandleDone( + new GameEvents.CharacterConfirmationDone(7u, 100u))); + Assert.True(factory.IsOpen); + Assert.True(controller.HandleDone( + new GameEvents.CharacterConfirmationDone(7u, 99u))); + + Assert.False(factory.IsOpen); + Assert.Equal([(7u, 99u, false)], responses); + } + +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs deleted file mode 100644 index a46a3bc9..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -public sealed class RetailConfirmationDialogServiceTests -{ - [Fact] - public void FixtureBuildsProductionConfirmationWidgets() - { - var layout = FixtureLoader.LoadConfirmationDialog(); - - Assert.IsType(layout.Root); - Assert.IsType(layout.FindElement(RetailConfirmationDialogService.MessageElementId)); - Assert.IsType(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId)); - Assert.IsType(layout.FindElement(RetailConfirmationDialogService.RejectButtonId)); - } - - [Fact] - public void ShowCentersModalAndAcceptCompletesIt() - { - var root = new UiRoot { Width = 1024f, Height = 768f }; - var layout = FixtureLoader.LoadConfirmationDialog(); - var service = new RetailConfirmationDialogService(root, layout); - bool? result = null; - - service.Show("Do you really want to kill your character?", accepted => result = accepted); - - Assert.Same(layout.Root, root.Modal); - var popup = Assert.IsAssignableFrom( - layout.FindElement(RetailConfirmationDialogService.PopupElementId)); - Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left); - Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top); - - var accept = Assert.IsType( - layout.FindElement(RetailConfirmationDialogService.AcceptButtonId)); - accept.OnClick!(); - - Assert.True(result); - Assert.Null(root.Modal); - Assert.False(service.IsOpen); - } - - [Fact] - public void RequestsArePresentedInFifoOrder() - { - var root = new UiRoot { Width = 800f, Height = 600f }; - var layout = FixtureLoader.LoadConfirmationDialog(); - var service = new RetailConfirmationDialogService(root, layout); - var results = new List(); - - service.Show("first", accepted => results.Add($"first:{accepted}")); - service.Show("second", accepted => results.Add($"second:{accepted}")); - - Assert.Equal(1, service.PendingCount); - Assert.IsType(layout.FindElement( - RetailConfirmationDialogService.RejectButtonId)).OnClick!(); - Assert.Equal(["first:False"], results); - Assert.True(service.IsOpen); - Assert.Equal(0, service.PendingCount); - - Assert.IsType(layout.FindElement( - RetailConfirmationDialogService.AcceptButtonId)).OnClick!(); - Assert.Equal(["first:False", "second:True"], results); - Assert.False(service.IsOpen); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs new file mode 100644 index 00000000..fecb6e43 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -0,0 +1,213 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class RetailDialogFactoryTests +{ + [Theory] + [InlineData(RetailDialogType.Confirmation, 0x15u)] + [InlineData(RetailDialogType.Wait, 0x31u)] + [InlineData(RetailDialogType.Message, 0x24u)] + [InlineData(RetailDialogType.TextInput, 0x28u)] + [InlineData(RetailDialogType.ConfirmationTextInput, 0x2Cu)] + [InlineData(RetailDialogType.Menu, 0x1Bu)] + [InlineData(RetailDialogType.ConfirmationMenu, 0x1Fu)] + public void DialogTypesMapToRetailCatalogRoots( + RetailDialogType type, + uint expectedRoot) + => Assert.Equal(expectedRoot, RetailDialogFactory.RootElementId(type)); + + [Fact] + public void FixtureBuildsProductionConfirmationWidgets() + { + var layout = FixtureLoader.LoadConfirmationDialog(); + + Assert.IsType(layout.Root); + Assert.IsType(layout.FindElement(RetailConfirmationDialogView.MessageElementId)); + Assert.IsType(layout.FindElement(RetailConfirmationDialogView.AcceptButtonId)); + Assert.IsType(layout.FindElement(RetailConfirmationDialogView.RejectButtonId)); + } + + [Fact] + public void ConfirmationCreatesFreshCenteredRootAndReturnsPropertyResult() + { + var root = new UiRoot { Width = 1024f, Height = 768f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + bool? result = null; + + factory.MakeConfirmation( + "Do you really want to kill your character?", + data => result = data.GetBoolean(RetailDialogProperty.ConfirmationResult)); + + ImportedLayout layout = Assert.Single(layouts); + Assert.Same(layout.Root, root.Modal); + var popup = Assert.IsAssignableFrom( + layout.FindElement(RetailConfirmationDialogView.PopupElementId)); + Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left); + Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top); + + Accept(layout); + + Assert.True(result); + Assert.Null(root.Modal); + Assert.False(factory.IsOpen); + } + + [Fact] + public void SameQueuePresentsFifoUsingFreshLiveRoots() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + var results = new List(); + + factory.MakeConfirmation("first", data => + results.Add($"first:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}")); + factory.MakeConfirmation("second", data => + results.Add($"second:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}")); + + Assert.Equal(1, factory.PendingCount); + Reject(layouts[0]); + Assert.Equal(["first:False"], results); + Assert.Equal(2, layouts.Count); + Assert.NotSame(layouts[0].Root, layouts[1].Root); + Assert.Same(layouts[1].Root, root.Modal); + Assert.Equal("second", Message(layouts[1])); + + Accept(layouts[1]); + Assert.Equal(["first:False", "second:True"], results); + Assert.False(factory.IsOpen); + } + + [Fact] + public void QueueGroupsAndNonQueuedDialogsCanBeActiveTogether() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + + uint first = factory.MakeConfirmation("queue two", queueKey: 2u); + uint second = factory.MakeConfirmation("queue three", queueKey: 3u); + uint third = factory.MakeConfirmation("nonqueued", queueKey: 1u); + + Assert.Equal(3, factory.ActiveCount); + Assert.Equal(0, factory.PendingCount); + Assert.Same(layouts[2].Root, root.Modal); + + Assert.True(factory.CloseDialog(third)); + Assert.Same(layouts[1].Root, root.Modal); + Assert.True(factory.CloseDialog(second)); + Assert.Same(layouts[0].Root, root.Modal); + Assert.True(factory.CloseDialog(first)); + Assert.Null(root.Modal); + } + + [Fact] + public void PriorityDialogSuspendsCurrentAndRestoresItBeforeOlderPendingWork() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + + factory.MakeConfirmation("current"); + factory.MakeConfirmation("ordinary pending"); + factory.MakeConfirmation("priority", priority: true); + + Assert.Equal(2, factory.PendingCount); + Assert.Equal("priority", Message(layouts[1])); + Assert.DoesNotContain(layouts[0].Root, root.Children); + + Reject(layouts[1]); + + Assert.Equal(3, layouts.Count); + Assert.Equal("current", Message(layouts[2])); + Assert.Equal(1, factory.PendingCount); + Assert.NotSame(layouts[0].Root, layouts[2].Root); + } + + [Fact] + public void CallbackPrecedesCloseNoticeAndCustomLabelsAreApplied() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + var order = new List(); + factory.DialogClosed += (_, _) => order.Add("notice"); + RetailDialogData data = RetailDialogData.Confirmation("Proceed?") + .Set(RetailDialogProperty.AcceptLabel, "Yes") + .Set(RetailDialogProperty.RejectLabel, "No"); + + factory.MakeDialog(data, _ => order.Add("callback")); + + Assert.Equal("Yes", Button(layouts[0], RetailConfirmationDialogView.AcceptButtonId).Label); + Assert.Equal("No", Button(layouts[0], RetailConfirmationDialogView.RejectButtonId).Label); + Accept(layouts[0]); + Assert.Equal(["callback", "notice"], order); + } + + [Fact] + public void PendingContextCanBeClosedWithoutDisturbingActiveDialog() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + var completed = new List(); + + uint active = factory.MakeConfirmation("active"); + uint pending = 0u; + pending = factory.MakeConfirmation("pending", _ => completed.Add(pending)); + + Assert.True(factory.CloseDialog(pending)); + Assert.Equal([pending], completed); + Assert.Equal(0, factory.PendingCount); + Assert.Same(layouts[0].Root, root.Modal); + Assert.True(factory.CloseDialog(active)); + } + + [Fact] + public void ResetCompletesActiveAndPendingContexts() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + var completed = new List(); + var notices = new List(); + factory.DialogClosed += (context, _) => notices.Add(context); + + uint active = factory.MakeConfirmation("active", _ => completed.Add("active")); + uint pending = factory.MakeConfirmation("pending", _ => completed.Add("pending")); + factory.Reset(); + + Assert.Equal(["active", "pending"], completed); + Assert.Equal([active, pending], notices); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + } + + private static RetailDialogFactory CreateFactory( + UiRoot root, + List layouts) + => new(root, type => + { + Assert.Equal(RetailDialogType.Confirmation, type); + ImportedLayout layout = FixtureLoader.LoadConfirmationDialog(); + layouts.Add(layout); + return layout; + }); + + private static UiButton Button(ImportedLayout layout, uint id) + => Assert.IsType(layout.FindElement(id)); + + private static void Accept(ImportedLayout layout) + => Button(layout, RetailConfirmationDialogView.AcceptButtonId).OnClick!(); + + private static void Reject(ImportedLayout layout) + => Button(layout, RetailConfirmationDialogView.RejectButtonId).OnClick!(); + + private static string Message(ImportedLayout layout) + => string.Join(" ", Assert.IsType(layout.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs index 4316864b..2c959a75 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs @@ -53,7 +53,7 @@ public sealed class RetailLayoutFixtureGenerator var dialogs = LayoutImporter.ImportInfos( dats, dialogsLayoutDid, - RetailConfirmationDialogService.RootElementId); + RetailDialogFactory.RootElementId(RetailDialogType.Confirmation)); Assert.NotNull(dialogs); var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions { diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs new file mode 100644 index 00000000..3d5b179a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -0,0 +1,102 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.Tests.UI.Layout; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailItemConfirmationControllerTests +{ + private const uint Player = 0x50000001u; + private const uint Pack = 0x50000002u; + private const uint Rare = 0x50000003u; + + [Fact] + public void VolatileRareSendsUseOnlyAfterPositiveFactoryCallback() + { + var objects = BuildObjects(PublicWeenieFlags.VolatileRare); + var uses = new List(); + var items = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: uses.Add, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1000L); + var root = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout? shown = null; + var factory = new RetailDialogFactory(root, _ => + shown = FixtureLoader.LoadConfirmationDialog()); + using var confirmations = new RetailItemConfirmationController(factory, items); + + Assert.True(items.ActivateItem(Rare)); + Assert.Empty(uses); + Assert.Equal(0, items.BusyCount); + Assert.Equal( + RetailItemConfirmationController.VolatileRareMessage, + string.Join(" ", Assert.IsType(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text))); + + Assert.IsType(shown.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.Equal([Rare], uses); + Assert.Equal(1, items.BusyCount); + } + + [Fact] + public void RejectedPlayerKillerAltarDoesNotSendUse() + { + var objects = BuildObjects(PublicWeenieFlags.PlayerKillerSwitch); + var uses = new List(); + var items = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: uses.Add, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1000L); + var root = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout? shown = null; + var factory = new RetailDialogFactory(root, _ => + shown = FixtureLoader.LoadConfirmationDialog()); + using var confirmations = new RetailItemConfirmationController(factory, items); + + Assert.True(items.ActivateItem(Rare)); + Assert.IsType(shown!.FindElement( + RetailConfirmationDialogView.RejectButtonId)).OnClick!(); + + Assert.Empty(uses); + Assert.Equal(0, items.BusyCount); + } + + private static ClientObjectTable BuildObjects(PublicWeenieFlags flags) + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + Type = ItemType.Creature, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Pack, + Name = "Backpack", + Type = ItemType.Container, + }); + objects.MoveItem(Pack, Player, 0); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Rare, + Name = "Rare", + Type = ItemType.Misc, + Useability = ItemUseability.Contained, + PublicWeenieBitfield = (uint)flags, + }); + objects.MoveItem(Rare, Pack, 0); + return objects; + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 785ab039..95feaf85 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -174,6 +174,30 @@ public sealed class GameEventWiringTests Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received); } + [Fact] + public void WireAll_ConfirmationDone_UsesRetailTypeAndContextTuple() + { + var dispatcher = new GameEventDispatcher(); + GameEvents.CharacterConfirmationDone? received = null; + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + new Spellbook(), + new ChatLog(), + onConfirmationDone: done => received = done); + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 6u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 314u); + + var env = GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.CharacterConfirmationDone, + payload)); + dispatcher.Dispatch(env!.Value); + + Assert.Equal(new GameEvents.CharacterConfirmationDone(6u, 314u), received); + } + [Fact] public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState() {