diff --git a/AGENTS.md b/AGENTS.md index f3e7514e..b4af475e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,477 tests pass / 5 skip. +Release builds; 7,524 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,7 +127,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–E are also complete: dead host residue is removed, +Slice 8 checkpoints A–F are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -136,11 +136,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state. camera input/focus/scroll and physical framebuffer publication. Silk, dispatcher, retained-UI, and devtools device edges are reversible, transactional, and terminal after disposal; live-session retirement precedes -physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 -methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The -7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and -395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action -routing, settings, resource ownership, ordered composition, +physical callback removal. `GameplayInputActionRouter` now owns the sole +semantic action-priority +graph; typed owners route combat, diagnostics, and remaining commands, and the +retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / +198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign +baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, +and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— +settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index 4274735b..1ddc71c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,477 tests pass / 5 skip. +Release builds; 7,524 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,7 +125,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–E are also complete: dead host residue is removed, +Slice 8 checkpoints A–F are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -134,11 +134,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state. camera input/focus/scroll and physical framebuffer publication. Silk, dispatcher, retained-UI, and devtools device edges are reversible, transactional, and terminal after disposal; live-session retirement precedes -physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 -methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The -7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and -395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action -routing, settings, resource ownership, ordered composition, +physical callback removal. `GameplayInputActionRouter` now owns the sole +semantic action-priority +graph; typed owners route combat, diagnostics, and remaining commands, and the +retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / +198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign +baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, +and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— +settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 38412aca..194d46b8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,12 +27,13 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–E now own native callbacks, live-session + complete; Slice 8 checkpoints A–F now own native callbacks, live-session composition, world-environment state, reversible device input/raw pointer, - and physical framebuffer publication outside the shell. + physical framebuffer publication, gameplay action priority, typed commands, + and the retained-root item-drop edge outside the shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining - Slice 8 action-routing/settings/composition/shutdown + handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining + Slice 8 settings/resource/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index ea8971d5..a5f62057 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -625,11 +625,12 @@ The accepted host order above is preserved as TS-53; it is not overclaimed as the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and `memory/project_gamewindow_decomposition.md`. -The render callback currently owns the GPU frame boundary and draw graph. Slice -7 extracts that graph without changing its order: per-resource frame begins and -render-thread uploads; world/PView and its two shared-alpha scopes; portal and -paperdoll private viewports; retained gameplay UI; ImGui devtools; screenshot; -then the GPU-flight close in `finally`. Both UI stacks coexist in the same frame. +`GameWindow.OnRender` likewise owns only one handoff to the shipped +`RenderFrameOrchestrator`. Its frozen graph begins per-resource frames and +render-thread uploads; draws world/PView and its two shared-alpha scopes; +renders portal and paperdoll private viewports, retained gameplay UI, ImGui +devtools, and screenshots; then closes the GPU-flight transaction in +`finally`. Both UI stacks coexist in the same frame. --- @@ -828,10 +829,11 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–E now own the native + update/render frame graphs. Slice 8 checkpoints A–F now own the native callback shell, live-session composition, world environment, reversible - input/pointer lifetime, and physical framebuffer publication. Gameplay action - routing is the next checkpoint. The reconciled ledger and sequence live in + input/pointer lifetime, physical framebuffer publication, the sole gameplay + action-priority graph, typed commands, and the retained-root item-drop edge. + Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index c53770e1..3cf636e6 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -32,6 +32,8 @@ after Slice 5 closeout 8,811 lines / 247 fields / 153 method after Slice 6 closeout 7,026 lines / 241 fields / 108 methods after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods +after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods +after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods ``` `GameWindow` is the single object that: @@ -690,15 +692,21 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoint E is complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E and F are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and terminal after disposal. Logical cutoff precedes live-session retirement; physical detach follows it, so neither copied callbacks nor a failed event -remove can interfere with graceful transport teardown. `GameWindow` is now -4,266 raw lines / 194 fields / 65 methods. Checkpoints F–L remain active, with -gameplay action routing next. +remove can interfere with graceful transport teardown. + +`GameplayInputActionRouter` is now the sole `InputDispatcher.Fired` +subscriber and preserves the accepted input-priority graph. Focused combat, +diagnostic, and general command owners replace the remaining window command +bodies. The retained-root item-drop edge has one reversible owner, while its +deferred toolbar targets become inert before session retirement. `GameWindow` +is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints G–L remain +active, with two-phase runtime settings next. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 92dedd0f..4b46c03b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–E are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–F are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–E now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–F now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -87,9 +87,12 @@ dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner; and one physical framebuffer-size publisher. Logical callback cutoff and live-session retirement precede physical removal, so failed device detach cannot block graceful transport close. `GameWindow` is 4,266 lines / 194 -fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5 -skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak, -and closeout checkpoints F–L remain. +fields / 65 methods. Checkpoint F adds the sole gameplay action-priority +subscriber, typed combat/diagnostic/general commands, and a reversible +retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / +54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips. +Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints +G–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index e733d96a..84fb2e4e 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,15 +39,17 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–E are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–F are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, transactional ownership for Silk/dispatcher/retained/devtools input, the raw camera pointer, focus, and physical framebuffer size. Graceful live-session -retirement now precedes physical callback removal. `GameWindow` is 4,266 lines -/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints F–L -remain active. Issue #232 tracks process-residency variance in the soak without +retirement now precedes physical callback removal. Checkpoint F adds the sole +gameplay action-priority owner, typed combat/diagnostic/general commands, and a +reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198 +fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints G–L remain +active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 79532b69..194b910a 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -23,7 +23,7 @@ audit. - [x] D — extract retail world-environment/day/weather behavior. - [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer resize behind symmetric named subscriptions. -- [ ] F — extract the sole gameplay input-action router plus focused combat and +- [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. - [ ] G — extract two-phase persisted settings/display/quality ownership. - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots @@ -527,6 +527,23 @@ connected lifecycle and visual gates. - Make Combat, Camera, dispatcher, and retained-root subscriptions named, transactional, and symmetrically detached. +Result: `GameplayInputActionRouter` is the sole gameplay subscriber to +`InputDispatcher.Fired` and preserves the frozen pointer → scroll → combat → +Press/DoubleClick gate → retained UI → selection → movement → command +priority. Focused command owners now route combat, diagnostics, window/player +mode, item-target mode, and Escape without calling feature bodies on +`GameWindow`. `RetainedUiGameplayBinding` owns the outside-UI item-drop edge; +the combat/diagnostic deferred slots release their targets before session +retirement, and the retained binding detaches transactionally afterward. +Named-retail review +also corrected `ToggleCombatMode @ 0x0056C8C0`: active combat short-circuits to +peace without an equipment lookup, while an incompatible Held object prints +the exact retail notice and sends no mode request. Three corrected-diff review +loops are clean; 54 focused App tests, 20 Core combat tests, the App suite +(3,155 pass / 3 intentional skips), the Release build, and the complete suite +(7,524 pass / 5 intentional skips) pass. `GameWindow` is 4,057 raw lines / 198 +fields / 54 methods. + ### G — two-phase runtime settings - Construct `RuntimeSettingsController` before Window.Create and source startup diff --git a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md index 1e7e52aa..6f6cbc07 100644 --- a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md +++ b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md @@ -3,6 +3,8 @@ ## Sources - `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310` +- `ClientCombatSystem::SetCombatMode @ 0x0056BE30` +- `ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0` - `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00` - `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0` - `SmartBox::HandleParentEvent @ 0x004535D0` @@ -52,10 +54,28 @@ GetDefaultCombatMode(showError): return Magic if showError == false: - print the retail cannot-use-held-item notice + print "You can't enter combat mode while wielding the %s" + formatted with held.GetObjectName(NAME_APPROPRIATE) return NonCombat ``` +`ToggleCombatMode` preserves that rejected result verbatim: + +```text +ToggleCombatMode(): + if current combat mode != NonCombat: + SetCombatMode(NonCombat, send = true) + return + + requested = GetDefaultCombatMode(showError = false) + SetCombatMode(requested, send = true) +``` + +`SetCombatMode` immediately returns when `requested == current`. Therefore an +incompatible Held object prints the notice in `GetDefaultCombatMode`, returns +`NonCombat`, and produces no wire request or local mode transition. It is not +coerced to unarmed Melee. + `GetObjectAtLocation` walks the player's ordered inventory-placement list and returns the first entry whose location intersects the requested mask (and whose priority intersects the requested priority when that priority is non-zero). diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 156689b9..a5d1c192 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–E. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65 -methods: 11,457 lines (72.9%) were removed without changing accepted gameplay +Slice 8 checkpoints A–F. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54 +methods: 11,666 lines (74.2%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -18,6 +18,7 @@ or rendering behavior. | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | +| 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -43,6 +44,17 @@ run only after session retirement. This prevents both copied callback reentry and a failed event accessor from blocking F653/transport teardown. The carried non-retail post-filter camera scalars are explicitly registered as TS-56. +F adds `GameplayInputActionRouter` as the sole `InputDispatcher.Fired` +subscriber while preserving the frozen action priority. Typed command owners +replace the remaining combat, diagnostic, window/player mode, item-target, +and Escape bodies. `RetainedUiGameplayBinding` owns the outside-UI item-drop +event. The two deferred command slots release their targets before session +retirement; the retained binding alone detaches its physical edge +transactionally afterward. Retail +review also corrected the carried default-combat mismatch: active combat +short-circuits directly to peace, and an incompatible Held object prints the +exact retail notice without a wire request or local mode mutation. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -99,6 +111,10 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment + skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat + conformance tests pass, and all three independent corrected-diff reviews are + clean. - Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent corrected-diff reviews are clean. @@ -131,8 +147,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints F–L group the remaining action routing, settings, resource -ownership, startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints G–L group the remaining settings, resource ownership, +startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs new file mode 100644 index 00000000..c84cf5ab --- /dev/null +++ b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs @@ -0,0 +1,214 @@ +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.UI; +using AcDream.Core.Combat; +using AcDream.Core.Items; + +namespace AcDream.App.Combat; + +internal interface ILiveCombatModeAuthority +{ + bool IsInWorld { get; } + + void SendChangeCombatMode(CombatMode mode); +} + +internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session) + : ILiveCombatModeAuthority +{ + private readonly LiveSessionHost _session = session + ?? throw new ArgumentNullException(nameof(session)); + + public bool IsInWorld => + _session.IsInWorld && _session.CurrentSession is not null; + + public void SendChangeCombatMode(CombatMode mode) + { + if (!IsInWorld) + { + throw new InvalidOperationException( + "A combat-mode request requires an active in-world session."); + } + + _session.CurrentSession!.SendChangeCombatMode(mode); + } +} + +internal interface ICombatEquipmentSource +{ + IReadOnlyList GetOrderedEquipment(); +} + +internal sealed class LocalPlayerCombatEquipmentSource( + ClientObjectTable objects, + ILocalPlayerIdentitySource identity) : ICombatEquipmentSource +{ + private readonly ClientObjectTable _objects = objects + ?? throw new ArgumentNullException(nameof(objects)); + private readonly ILocalPlayerIdentitySource _identity = identity + ?? throw new ArgumentNullException(nameof(identity)); + + public IReadOnlyList GetOrderedEquipment() => + _objects.GetEquippedBy(_identity.ServerGuid); +} + +internal interface IExplicitCombatModeIntentSink +{ + void NotifyExplicitCombatModeRequest(); +} + +internal sealed class ItemInteractionCombatModeIntentSink( + ItemInteractionController items) : IExplicitCombatModeIntentSink +{ + private readonly ItemInteractionController _items = items + ?? throw new ArgumentNullException(nameof(items)); + + public void NotifyExplicitCombatModeRequest() => + _items.NotifyExplicitCombatModeRequest(); +} + +internal interface ILiveCombatModeCommand +{ + void Toggle(); +} + +/// +/// Focused deferred edge shared by the early-created retained toolbar and the +/// later-created gameplay router. It owns no combat state and forwards to one +/// window-lifetime command owner after session composition is complete. +/// +internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand +{ + private readonly object _gate = new(); + private ILiveCombatModeCommand? _target; + private bool _deactivated; + + public void Bind(ILiveCombatModeCommand target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + { + throw new InvalidOperationException( + "Live combat-mode commands are already bound."); + } + + _target = target; + } + } + + public void Unbind(ILiveCombatModeCommand target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + if (ReferenceEquals(_target, target)) + _target = null; + } + } + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _target = null; + } + } + + public void Toggle() + { + lock (_gate) + { + if (!_deactivated) + _target?.Toggle(); + } + } +} + +/// +/// Owns the input-facing combat-mode command. The choice itself remains the +/// retail port in : +/// ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310 and +/// ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0. ACE remains the +/// authority; the local state update preserves the accepted immediate toolbar +/// response while the server confirmation travels through the normal route. +/// +internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand +{ + private readonly ILiveCombatModeAuthority _authority; + private readonly ICombatEquipmentSource _equipment; + private readonly CombatState _combat; + private readonly IExplicitCombatModeIntentSink _itemIntent; + private readonly Action _log; + private readonly Action? _toast; + private readonly Action _systemMessage; + + public LiveCombatModeCommandController( + ILiveCombatModeAuthority authority, + ICombatEquipmentSource equipment, + CombatState combat, + IExplicitCombatModeIntentSink itemIntent, + Action? log = null, + Action? toast = null, + Action? systemMessage = null) + { + _authority = authority ?? throw new ArgumentNullException(nameof(authority)); + _equipment = equipment ?? throw new ArgumentNullException(nameof(equipment)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent)); + _log = log ?? (_ => { }); + _toast = toast; + _systemMessage = systemMessage ?? (_ => { }); + } + + public void Toggle() + { + if (!_authority.IsInWorld) + return; + + // Every explicit user combat request supersedes an in-flight auto-wield + // settlement, including a request retail rejects before SetCombatMode. + _itemIntent.NotifyExplicitCombatModeRequest(); + + CombatMode currentMode = _combat.CurrentMode; + CombatMode nextMode; + if (currentMode != CombatMode.NonCombat) + { + // ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and + // never queries default equipment on this branch. + nextMode = CombatMode.NonCombat; + } + else + { + DefaultCombatModeDecision defaultDecision = + CombatInputPlanner.GetDefaultCombatModeDecision( + _equipment.GetOrderedEquipment()); + nextMode = CombatInputPlanner.ToggleMode( + currentMode, + defaultDecision.Mode); + + // GetDefaultCombatMode prints this notice before SetCombatMode. + // SetCombatMode then sees NonCombat == NonCombat and does no work. + if (defaultDecision.IncompatibleHeldItem is { } held) + { + string notice = + $"You can't enter combat mode while wielding the {held.GetAppropriateName()}"; + _log($"combat: {notice}"); + _systemMessage(notice); + return; + } + } + + // Preserve the accepted command order: explicit intent was published + // above, then send the request and publish local presentation. + _authority.SendChangeCombatMode(nextMode); + _combat.SetCombatMode(nextMode); + + string message = $"Combat mode {nextMode}"; + _log($"combat: {message}"); + _toast?.Invoke(message); + } +} diff --git a/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs new file mode 100644 index 00000000..b5d1926f --- /dev/null +++ b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs @@ -0,0 +1,340 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Diagnostics; + +internal interface IRuntimeDiagnosticCommands +{ + bool Handle(InputAction action); + + void CycleTimeOfDay(); + + void CycleWeather(); + + void ToggleCollisionWireframes(); +} + +/// +/// Focused deferred edge used by the early-created developer panel and the +/// later-created gameplay router. It owns no diagnostic state and binds one +/// window-lifetime command owner exactly once. +/// +internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands +{ + private readonly object _gate = new(); + private IRuntimeDiagnosticCommands? _target; + private bool _deactivated; + + public void Bind(IRuntimeDiagnosticCommands target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + { + throw new InvalidOperationException( + "Runtime diagnostic commands are already bound."); + } + + _target = target; + } + } + + public void Unbind(IRuntimeDiagnosticCommands target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + if (ReferenceEquals(_target, target)) + _target = null; + } + } + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _target = null; + } + } + + public bool Handle(InputAction action) + { + lock (_gate) + { + if (!_deactivated && _target is { } target) + return target.Handle(action); + } + + return RuntimeDiagnosticCommandController.IsDiagnosticAction(action); + } + + public void CycleTimeOfDay() + { + lock (_gate) + { + if (!_deactivated) + _target?.CycleTimeOfDay(); + } + } + + public void CycleWeather() + { + lock (_gate) + { + if (!_deactivated) + _target?.CycleWeather(); + } + } + + public void ToggleCollisionWireframes() + { + lock (_gate) + { + if (!_deactivated) + _target?.ToggleCollisionWireframes(); + } + } +} + +internal interface INearbyWorldDiagnosticSource +{ + Vector3 ObserverPosition { get; } + + int LiveCenterX { get; } + + int LiveCenterY { get; } + + int TotalShadowObjects { get; } + + IReadOnlyList WorldEntities { get; } + + IEnumerable ShadowEntries { get; } +} + +/// +/// Read-only view over canonical runtime owners. It does not cache the current +/// player, camera, entity list, origin, or shadow registry. +/// +internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource +{ + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _player; + private readonly CameraController _camera; + private readonly LiveWorldOriginState _origin; + private readonly GpuWorldState _world; + private readonly PhysicsEngine _physics; + + public RuntimeNearbyWorldDiagnosticSource( + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource player, + CameraController camera, + LiveWorldOriginState origin, + GpuWorldState world, + PhysicsEngine physics) + { + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + } + + public Vector3 ObserverPosition + { + get + { + if (_mode.IsPlayerMode && _player.Controller is { } player) + return player.Position; + + Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView); + return new Vector3( + inverseView.M41, + inverseView.M42, + inverseView.M43); + } + } + + public int LiveCenterX => _origin.CenterX; + + public int LiveCenterY => _origin.CenterY; + + public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered; + + public IReadOnlyList WorldEntities => _world.Entities; + + public IEnumerable ShadowEntries => + _physics.ShadowObjects.AllEntriesForDebug(); +} + +internal sealed class NearbyWorldDiagnosticDumper +{ + private const float NearbyDistance = 15f; + private readonly INearbyWorldDiagnosticSource _source; + private readonly Action _log; + + public NearbyWorldDiagnosticDumper( + INearbyWorldDiagnosticSource source, + Action? log = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _log = log ?? (_ => { }); + } + + public void Dump() + { + Vector3 position = _source.ObserverPosition; + int centerX = _source.LiveCenterX; + int centerY = _source.LiveCenterY; + int landblockX = centerX + (int)MathF.Floor(position.X / 192f); + int landblockY = centerY + (int)MathF.Floor(position.Y / 192f); + + _log( + $"=== F3 DEBUG DUMP ===\n" + + $" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" + + $" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " + + $"local=({position.X - (landblockX - centerX) * 192f:F2}," + + $"{position.Y - (landblockY - centerY) * 192f:F2})\n" + + $" total shadow objects: {_source.TotalShadowObjects}"); + + List visibleNearby = _source.WorldEntities + .Where(entity => HorizontalDistanceSquared(entity.Position, position) + < NearbyDistance * NearbyDistance) + .OrderBy(entity => (entity.Position - position).Length()) + .ToList(); + _log($" VISIBLE entities within 15m: {visibleNearby.Count}"); + foreach (WorldEntity entity in visibleNearby.Take(12)) + { + float distance = (entity.Position - position).Length(); + _log( + $" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " + + $"dist={distance:F2} scale={entity.Scale:F2}"); + } + + List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries + .Select(entry => + { + float dx = entry.Position.X - position.X; + float dy = entry.Position.Y - position.Y; + return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy)); + }) + .Where(item => item.Distance < NearbyDistance) + .OrderBy(item => item.Distance) + .ToList(); + _log($" SHADOW objects within 15m: {shadows.Count}"); + foreach ((ShadowEntry entry, float distance) in shadows.Take(12)) + { + _log( + $" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " + + $"r={entry.Radius:F2} h={entry.CylHeight:F2} " + + $"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " + + $"dist={distance:F2}"); + } + } + + private static float HorizontalDistanceSquared(Vector3 left, Vector3 right) + { + float dx = left.X - right.X; + float dy = left.Y - right.Y; + return dx * dx + dy * dy; + } +} + +/// +/// Routes developer-only semantic actions while leaving every mutable value in +/// its canonical owner. No diagnostic command owns a second clock, weather, +/// camera-sensitivity, collision, entity, or shadow state. +/// +internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands +{ + private readonly WorldEnvironmentController _environment; + private readonly WorldSceneDebugState _sceneDebug; + private readonly IPointerSensitivityCommands? _pointer; + private readonly NearbyWorldDiagnosticDumper _nearby; + private readonly Action? _toast; + + public RuntimeDiagnosticCommandController( + WorldEnvironmentController environment, + WorldSceneDebugState sceneDebug, + IPointerSensitivityCommands? pointer, + NearbyWorldDiagnosticDumper nearby, + Action? toast = null) + { + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug)); + _pointer = pointer; + _nearby = nearby ?? throw new ArgumentNullException(nameof(nearby)); + _toast = toast; + } + + public bool Handle(InputAction action) + { + switch (action) + { + case InputAction.AcdreamToggleCollisionWires: + ToggleCollisionWireframes(); + return true; + case InputAction.AcdreamDumpNearby: + _nearby.Dump(); + return true; + case InputAction.AcdreamCycleTimeOfDay: + CycleTimeOfDay(); + return true; + case InputAction.AcdreamSensitivityDown: + if (_pointer is not null) + { + string message = _pointer.AdjustSensitivity(1f / 1.2f); + _toast?.Invoke(message); + } + return true; + case InputAction.AcdreamSensitivityUp: + if (_pointer is not null) + { + string message = _pointer.AdjustSensitivity(1.2f); + _toast?.Invoke(message); + } + return true; + case InputAction.AcdreamCycleWeather: + CycleWeather(); + return true; + default: + return false; + } + } + + public void CycleTimeOfDay() + { + string message = _environment.CycleTimeOfDay(); + _toast?.Invoke(message); + } + + public void CycleWeather() + { + string message = _environment.CycleWeather(); + _toast?.Invoke(message); + } + + public void ToggleCollisionWireframes() + { + bool visible = _sceneDebug.ToggleCollisionWireframes(); + _toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}"); + } + + internal static bool IsDiagnosticAction(InputAction action) => action is + InputAction.AcdreamToggleCollisionWires + or InputAction.AcdreamDumpNearby + or InputAction.AcdreamCycleTimeOfDay + or InputAction.AcdreamSensitivityDown + or InputAction.AcdreamSensitivityUp + or InputAction.AcdreamCycleWeather; +} diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index 22d43807..fcbb78fe 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse) } } +internal interface IPointerSensitivityCommands +{ + string AdjustSensitivity(float factor); +} + /// /// Owns raw pointer subscriptions and the camera-facing pointer policy that /// previously lived in GameWindow. Physical event removal is retriable; logical /// deactivation is immediate, so a copied or stubborn Silk delegate cannot /// re-enter gameplay while shutdown is closing the live session. /// -internal sealed class CameraPointerInputController : IDisposable +internal sealed class CameraPointerInputController + : IDisposable, IPointerSensitivityCommands { private readonly IReadOnlyList _surfaces; private readonly IPointerCursorModeTarget _cursor; diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs new file mode 100644 index 00000000..5800bd0d --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -0,0 +1,316 @@ +using AcDream.App.Interaction; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IGameplayInputActionSurface +{ + void AddFired(Action callback); + + void RemoveFired(Action callback); + + void SetCombatScope(InputScope? scope); +} + +internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) + : IGameplayInputActionSurface +{ + private readonly InputDispatcher _dispatcher = dispatcher + ?? throw new ArgumentNullException(nameof(dispatcher)); + + public void AddFired(Action callback) => + _dispatcher.Fired += callback; + + public void RemoveFired(Action callback) => + _dispatcher.Fired -= callback; + + public void SetCombatScope(InputScope? scope) => + _dispatcher.SetCombatScope(scope); +} + +internal interface ICombatModeEventSurface +{ + CombatMode CurrentMode { get; } + + void AddChanged(Action callback); + + void RemoveChanged(Action callback); +} + +internal sealed class CombatStateModeEventSurface(CombatState combat) + : ICombatModeEventSurface +{ + private readonly CombatState _combat = combat + ?? throw new ArgumentNullException(nameof(combat)); + + public CombatMode CurrentMode => _combat.CurrentMode; + + public void AddChanged(Action callback) => + _combat.CombatModeChanged += callback; + + public void RemoveChanged(Action callback) => + _combat.CombatModeChanged -= callback; +} + +internal interface IGameplayInputPriorityTargets +{ + bool HandlePointerAction(InputAction action, ActivationType activation); + + void HandleScroll(InputAction action); + + bool HandleCombatAction(InputAction action, ActivationType activation); + + bool HandleRetainedUiAction(InputAction action); + + bool HandleSelectionAction(InputAction action); + + bool HandlePressedMovementAction(InputAction action); + + void HandleCommand(InputAction action); +} + +/// +/// Typed adapter over canonical gameplay owners. Optional presentation owners +/// remain no-ops when their stack is disabled; no state is mirrored here. +/// +internal sealed class RuntimeGameplayInputPriorityTargets + : IGameplayInputPriorityTargets +{ + private readonly GameplayInputFrameController _frame; + private readonly CameraPointerInputController _pointer; + private readonly RetailUiRuntime? _retainedUi; + private readonly SelectionInteractionController? _selection; + private readonly IGameplayInputCommandTarget _commands; + + public RuntimeGameplayInputPriorityTargets( + GameplayInputFrameController frame, + CameraPointerInputController pointer, + RetailUiRuntime? retainedUi, + SelectionInteractionController? selection, + IGameplayInputCommandTarget commands) + { + _frame = frame ?? throw new ArgumentNullException(nameof(frame)); + _pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + _retainedUi = retainedUi; + _selection = selection; + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + } + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + _frame.HandlePointerAction(action, activation); + + public void HandleScroll(InputAction action) => + _pointer.HandleScroll(action); + + public bool HandleCombatAction(InputAction action, ActivationType activation) => + _frame.HandleCombatAction(action, activation); + + public bool HandleRetainedUiAction(InputAction action) => + _retainedUi?.HandleInputAction(action) == true; + + public bool HandleSelectionAction(InputAction action) => + _selection?.HandleInputAction(action) == true; + + public bool HandlePressedMovementAction(InputAction action) => + _frame.HandlePressedMovementAction(action); + + public void HandleCommand(InputAction action) => + _commands.Handle(action); +} + +/// +/// Sole gameplay subscriber to . The route +/// preserves the accepted priority exactly: pointer, scroll, combat, +/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands. +/// Subscription ownership is reversible and callbacks become inert before the +/// live-session shutdown barrier. +/// +internal sealed class GameplayInputActionRouter : IDisposable +{ + private readonly IGameplayInputActionSurface _actions; + private readonly ICombatModeEventSurface _combat; + private readonly IGameplayInputPriorityTargets _targets; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _log; + private readonly Action _fired; + private readonly Action _combatModeChanged; + private readonly bool[] _attached = new bool[2]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public GameplayInputActionRouter( + IGameplayInputActionSurface actions, + ICombatModeEventSurface combat, + IGameplayInputPriorityTargets targets, + HostQuiescenceGate quiescence, + Action? log = null) + { + _actions = actions ?? throw new ArgumentNullException(nameof(actions)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _log = log ?? (_ => { }); + _fired = OnFired; + _combatModeChanged = OnCombatModeChanged; + } + + public static GameplayInputActionRouter Create( + InputDispatcher dispatcher, + CombatState combat, + IGameplayInputPriorityTargets targets, + HostQuiescenceGate quiescence, + Action? log = null) => + new( + new DispatcherGameplayInputActionSurface(dispatcher), + new CombatStateModeEventSurface(combat), + targets, + quiescence, + log); + + public bool IsDisposalComplete => + _attached.All(static attached => !attached); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + { + throw new InvalidOperationException( + "Gameplay action attachment has already started."); + } + + _attachStarted = true; + try + { + // Mark each edge before invoking its custom accessor. An accessor + // may perform the add and then throw; rollback must still remove it. + _attached[0] = true; + _actions.AddFired(_fired); + _attached[1] = true; + _combat.AddChanged(_combatModeChanged); + + Volatile.Write(ref _active, 1); + SetCombatScope(_combat.CurrentMode); + } + catch (Exception attachError) + { + Deactivate(); + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Gameplay action registration and rollback both failed.", + new InvalidOperationException( + "Gameplay action registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Gameplay action registration failed and was rolled back.", + attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnFired(InputAction action, ActivationType activation) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + Route(action, activation); + }); + + private void OnCombatModeChanged(CombatMode mode) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + SetCombatScope(mode); + }); + + private void Route(InputAction action, ActivationType activation) + { + _log($"[input] {action} {activation}"); + + if (_targets.HandlePointerAction(action, activation)) + return; + + if (action is InputAction.ScrollUp or InputAction.ScrollDown) + { + if (activation == ActivationType.Press) + _targets.HandleScroll(action); + return; + } + + if (_targets.HandleCombatAction(action, activation)) + return; + + if (activation is not ActivationType.Press + and not ActivationType.DoubleClick) + { + return; + } + + if (_targets.HandleRetainedUiAction(action)) + return; + if (_targets.HandleSelectionAction(action)) + return; + if (_targets.HandlePressedMovementAction(action)) + return; + + _targets.HandleCommand(action); + } + + private void SetCombatScope(CombatMode mode) => + _actions.SetCombatScope(mode switch + { + CombatMode.Melee => InputScope.MeleeCombat, + CombatMode.Missile => InputScope.MissileCombat, + CombatMode.Magic => InputScope.MagicCombat, + _ => null, + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("gameplay action callbacks", + [ + new("combat mode", () => Remove(1)), + new("dispatcher fired", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) + return; + + switch (index) + { + case 0: + _actions.RemoveFired(_fired); + break; + case 1: + _combat.RemoveChanged(_combatModeChanged); + break; + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + + _attached[index] = false; + } +} diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs new file mode 100644 index 00000000..457bbf55 --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -0,0 +1,210 @@ +using AcDream.App.Combat; +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IRetainedGameplayWindowCommands +{ + void ToggleInventory(); +} + +internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) + : IRetainedGameplayWindowCommands +{ + private readonly RetailUiRuntime? _runtime = runtime; + + public void ToggleInventory() => + _runtime?.ToggleWindow(WindowNames.Inventory); +} + +internal interface IDevToolsGameplayCommands +{ + void ToggleDebugPanel(); + + void FocusChatInput(); + + void ToggleSettingsPanel(); +} + +internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter) + : IDevToolsGameplayCommands +{ + private readonly DevToolsFramePresenter? _presenter = presenter; + + public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel(); + + public void FocusChatInput() => _presenter?.FocusChatInput(); + + public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel(); +} + +internal interface IPlayerModeGameplayCommands +{ + bool IsPlayerMode { get; } + + void ToggleFlyOrChase(); + + void TogglePlayerMode(); + + void ExitPlayerMode(); +} + +internal sealed class PlayerModeGameplayCommands( + ILocalPlayerModeSource mode, + PlayerModeController controller) : IPlayerModeGameplayCommands +{ + private readonly ILocalPlayerModeSource _mode = mode + ?? throw new ArgumentNullException(nameof(mode)); + private readonly PlayerModeController _controller = controller + ?? throw new ArgumentNullException(nameof(controller)); + + public bool IsPlayerMode => _mode.IsPlayerMode; + + public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase(); + + public void TogglePlayerMode() => _controller.Toggle(); + + public void ExitPlayerMode() => _controller.Exit(); +} + +internal interface IItemTargetModeCommands +{ + bool IsAnyTargetModeActive { get; } + + void CancelTargetMode(); +} + +internal sealed class ItemTargetModeCommands(ItemInteractionController items) + : IItemTargetModeCommands +{ + private readonly ItemInteractionController _items = items + ?? throw new ArgumentNullException(nameof(items)); + + public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive; + + public void CancelTargetMode() => _items.CancelTargetMode(); +} + +internal interface IGameplayCameraModeCommands +{ + bool IsFlyMode { get; } + + void ExitFlyMode(); +} + +internal sealed class GameplayCameraModeCommands(CameraController camera) + : IGameplayCameraModeCommands +{ + private readonly CameraController _camera = camera + ?? throw new ArgumentNullException(nameof(camera)); + + public bool IsFlyMode => _camera.IsFlyMode; + + public void ExitFlyMode() => _camera.ToggleFly(); +} + +internal interface IGameplayWindowCommands +{ + void Close(); +} + +internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands +{ + private readonly Action _close = close + ?? throw new ArgumentNullException(nameof(close)); + + public void Close() => _close(); +} + +internal interface IGameplayInputCommandTarget +{ + bool Handle(InputAction action); +} + +/// +/// Owns the final command tier of the frozen gameplay-action priority graph. +/// It coordinates canonical owners but stores no UI, camera, player, combat, +/// item-target, or diagnostic state of its own. +/// +internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget +{ + private readonly IRetainedGameplayWindowCommands _retained; + private readonly IDevToolsGameplayCommands _devTools; + private readonly IRuntimeDiagnosticCommands _diagnostics; + private readonly IPlayerModeGameplayCommands _playerMode; + private readonly IItemTargetModeCommands _targetMode; + private readonly IGameplayCameraModeCommands _camera; + private readonly ILiveCombatModeCommand _combat; + private readonly IGameplayWindowCommands _window; + + public GameplayInputCommandController( + IRetainedGameplayWindowCommands retained, + IDevToolsGameplayCommands devTools, + IRuntimeDiagnosticCommands diagnostics, + IPlayerModeGameplayCommands playerMode, + IItemTargetModeCommands targetMode, + IGameplayCameraModeCommands camera, + ILiveCombatModeCommand combat, + IGameplayWindowCommands window) + { + _retained = retained ?? throw new ArgumentNullException(nameof(retained)); + _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public bool Handle(InputAction action) + { + if (_diagnostics.Handle(action)) + return true; + + switch (action) + { + case InputAction.ToggleInventoryPanel: + _retained.ToggleInventory(); + return true; + case InputAction.AcdreamToggleDebugPanel: + _devTools.ToggleDebugPanel(); + return true; + case InputAction.AcdreamToggleFlyMode: + _playerMode.ToggleFlyOrChase(); + return true; + case InputAction.AcdreamTogglePlayerMode: + _playerMode.TogglePlayerMode(); + return true; + case InputAction.ToggleChatEntry: + _devTools.FocusChatInput(); + return true; + case InputAction.ToggleOptionsPanel: + _devTools.ToggleSettingsPanel(); + return true; + case InputAction.CombatToggleCombat: + _combat.Toggle(); + return true; + case InputAction.EscapeKey: + HandleEscape(); + return true; + default: + return false; + } + } + + private void HandleEscape() + { + if (_targetMode.IsAnyTargetModeActive) + _targetMode.CancelTargetMode(); + else if (_camera.IsFlyMode) + _camera.ExitFlyMode(); + else if (_playerMode.IsPlayerMode) + _playerMode.ExitPlayerMode(); + else + _window.Close(); + } +} diff --git a/src/AcDream.App/Input/RetainedUiGameplayBinding.cs b/src/AcDream.App/Input/RetainedUiGameplayBinding.cs new file mode 100644 index 00000000..f4e0907d --- /dev/null +++ b/src/AcDream.App/Input/RetainedUiGameplayBinding.cs @@ -0,0 +1,139 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Input; + +internal interface IRetainedUiDragReleaseSurface +{ + void AddReleasedOutside(Action callback); + + void RemoveReleasedOutside(Action callback); +} + +internal sealed class UiRootDragReleaseSurface(UiRoot root) + : IRetainedUiDragReleaseSurface +{ + private readonly UiRoot _root = root + ?? throw new ArgumentNullException(nameof(root)); + + public void AddReleasedOutside(Action callback) => + _root.DragReleasedOutsideUi += callback; + + public void RemoveReleasedOutside(Action callback) => + _root.DragReleasedOutsideUi -= callback; +} + +/// +/// Owns the retained-root gameplay edge used when an item drag is released in +/// the world. Logical deactivation precedes live-session teardown and physical +/// removal remains retriable if a custom event accessor fails. +/// +internal sealed class RetainedUiGameplayBinding : IDisposable +{ + private readonly IRetainedUiDragReleaseSurface _surface; + private readonly Action _placeDraggedItem; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _releasedOutside; + private ResourceShutdownTransaction? _detach; + private bool _attached; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public RetainedUiGameplayBinding( + IRetainedUiDragReleaseSurface surface, + Action placeDraggedItem, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _placeDraggedItem = placeDraggedItem + ?? throw new ArgumentNullException(nameof(placeDraggedItem)); + _quiescence = quiescence + ?? throw new ArgumentNullException(nameof(quiescence)); + _releasedOutside = OnReleasedOutside; + } + + public static RetainedUiGameplayBinding Create( + UiRoot root, + Action placeDraggedItem, + HostQuiescenceGate quiescence) => + new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence); + + public bool IsDisposalComplete => !_attached; + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + { + throw new InvalidOperationException( + "Retained gameplay attachment has already started."); + } + + _attachStarted = true; + try + { + // Publish before the custom accessor: it may add and then throw. + _attached = true; + _surface.AddReleasedOutside(_releasedOutside); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Retained gameplay registration and rollback both failed.", + new InvalidOperationException( + "Retained gameplay registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Retained gameplay registration failed and was rolled back.", + attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnReleasedOutside(object payload, int x, int y) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0 + && payload is ItemDragPayload item) + { + _placeDraggedItem(item, x, y); + } + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("retained gameplay callbacks", + [ + new("drag released outside UI", Remove), + ])); + + private void Remove() + { + if (!_attached) + return; + + _surface.RemoveReleasedOutside(_releasedOutside); + _attached = false; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 17e01e66..e467ca3c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -447,12 +447,9 @@ public sealed class GameWindow : IDisposable // the bool here. private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; - // Phase K.1b — single input path. Every keyboard/mouse-button reaction - // flows through InputDispatcher.Fired (see OnInputAction below) or - // IsActionHeld (per-frame polling for movement). The legacy direct - // kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only - // mouse.MouseMove survives as a direct handler because mouse-delta is - // axis input, not chord input. + // Phase K.1b / Slice 8 F — one semantic input path. Transitional actions + // flow through GameplayInputActionRouter; held movement is polled through + // InputDispatcher. Raw axis motion belongs to CameraPointerInputController. private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; @@ -462,6 +459,12 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new(); private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; + private AcDream.App.Input.GameplayInputActionRouter? _gameplayInputActions; + private AcDream.App.Input.RetainedUiGameplayBinding? _retainedUiGameplayBinding; + private readonly AcDream.App.Diagnostics.RuntimeDiagnosticCommandSlot + _runtimeDiagnosticCommands = new(); + private readonly AcDream.App.Combat.LiveCombatModeCommandSlot + _liveCombatModeCommands = new(); // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, // falling back to the retail-faithful defaults if the file is missing // or corrupt. This is THE single source of truth for the keymap at @@ -693,10 +696,6 @@ public sealed class GameWindow : IDisposable _inputDispatcher.Attach(); _movementInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher); - _inputDispatcher.Fired += OnInputAction; - Combat.CombatModeChanged += SetInputCombatScope; - SetInputCombatScope(Combat.CurrentMode); - } _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); @@ -960,9 +959,12 @@ public sealed class GameWindow : IDisposable getFrameMs: () => (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), combat: Combat); - _debugVm.CycleTimeOfDay = CycleTimeOfDay; - _debugVm.CycleWeather = CycleWeather; - _debugVm.ToggleCollisionWires = ToggleCollisionWires; + _debugVm.CycleTimeOfDay = + _runtimeDiagnosticCommands.CycleTimeOfDay; + _debugVm.CycleWeather = + _runtimeDiagnosticCommands.CycleWeather; + _debugVm.ToggleCollisionWires = + _runtimeDiagnosticCommands.ToggleCollisionWireframes; // Phase K.2: free-fly toggle button — same routine the // legacy F-key alias hits. Cancels the one-shot // auto-entry if the user opts out of player mode before @@ -1406,8 +1408,6 @@ public sealed class GameWindow : IDisposable displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), incrementBusy: () => _itemInteractionController.IncrementBusyCount(), canSend: () => _liveSessionHost?.IsInWorld == true); - _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; - // Feed Silk input to the UiRoot tree so windows drag / close / select. // UiRoot consumes UI events; the game InputDispatcher (subscribed to the // same devices) is gated off via WantCaptureMouse/Keyboard above when the @@ -1577,7 +1577,7 @@ public sealed class GameWindow : IDisposable UseItemByGuid, Combat, ItemMana, - ToggleLiveCombatMode, + _liveCombatModeCommands.Toggle, _itemInteractionController, entry => LiveSession?.SendAddShortcut(entry), index => LiveSession?.SendRemoveShortcut(index), @@ -2007,6 +2007,17 @@ public sealed class GameWindow : IDisposable text => _debugVm?.AddToast(text), _playerApproachCompletions); } + if (_uiHost is { } retainedHost + && _selectionInteractions is { } selectionInteractions) + { + _retainedUiGameplayBinding = + AcDream.App.Input.RetainedUiGameplayBinding.Create( + retainedHost.Root, + (item, x, y) => + selectionInteractions.PlaceDraggedItem(item, x, y), + _hostQuiescence); + _retainedUiGameplayBinding.Attach(); + } // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -2813,6 +2824,81 @@ public sealed class GameWindow : IDisposable _playerModeAutoEntry), cameraFrame); _liveSessionHost = CreateLiveSessionHost(); + + AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm; + Action? debugToast = debugVm is null + ? null + : message => debugVm.AddToast(message); + AcDream.Core.Chat.ChatLog chat = Chat; + var combatCommand = + new AcDream.App.Combat.LiveCombatModeCommandController( + new AcDream.App.Combat.LiveSessionCombatModeAuthority( + _liveSessionHost), + new AcDream.App.Combat.LocalPlayerCombatEquipmentSource( + Objects, + _localPlayerIdentity), + Combat, + new AcDream.App.Combat.ItemInteractionCombatModeIntentSink( + _itemInteractionController!), + Console.WriteLine, + debugToast, + text => chat.OnSystemMessage(text, 0x1Au)); + _liveCombatModeCommands.Bind(combatCommand); + + var nearbyDiagnostics = + new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper( + new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource( + _localPlayerMode, + _playerControllerSlot, + _cameraController!, + _liveWorldOrigin, + _worldState, + _physicsEngine), + Console.WriteLine); + var runtimeDiagnostics = + new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController( + _worldEnvironment, + _worldSceneDebugState, + _cameraPointerInput, + nearbyDiagnostics, + debugToast); + _runtimeDiagnosticCommands.Bind(runtimeDiagnostics); + + if (_inputDispatcher is { } dispatcher) + { + var pointer = _cameraPointerInput + ?? throw new InvalidOperationException( + "Gameplay action routing requires the composed pointer owner."); + var commands = new AcDream.App.Input.GameplayInputCommandController( + new AcDream.App.Input.RetainedGameplayWindowCommands( + _retailUiRuntime), + new AcDream.App.Input.DevToolsGameplayCommands( + _devToolsFramePresenter), + runtimeDiagnostics, + new AcDream.App.Input.PlayerModeGameplayCommands( + _localPlayerMode, + _playerModeController), + new AcDream.App.Input.ItemTargetModeCommands( + _itemInteractionController!), + new AcDream.App.Input.GameplayCameraModeCommands( + _cameraController!), + combatCommand, + new AcDream.App.Input.GameplayWindowCommands(_window!.Close)); + var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets( + _gameplayInputFrame!, + pointer, + _retailUiRuntime, + _selectionInteractions, + commands); + _gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create( + dispatcher, + Combat, + targets, + _hostQuiescence, + Console.WriteLine); + _gameplayInputActions.Attach(); + } + AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionHost.Start(_options); switch (liveStart.Status) @@ -3241,36 +3327,6 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; - /// - /// Cycle the time-of-day debug override. Same body as the old F7 - /// keybind handler; called by both the keybind AND the DebugPanel - /// "Cycle time of day" button via DebugVM.CycleTimeOfDay. - /// - private void CycleTimeOfDay() - { - string message = _worldEnvironment.CycleTimeOfDay(); - _debugVm?.AddToast(message); - } - - /// - /// Cycle the weather kind. Same body as the old F10 keybind handler. - /// - private void CycleWeather() - { - string message = _worldEnvironment.CycleWeather(); - _debugVm?.AddToast(message); - } - - /// - /// Toggle the collision-wires debug renderer. Same body as the old - /// F2 keybind handler. - /// - private void ToggleCollisionWires() - { - bool visible = _worldSceneDebugState.ToggleCollisionWireframes(); - _debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}"); - } - // Phase K.3 settings state remains runtime-owned; the presenter owns the // optional SettingsPanel and its visibility/input operations. private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; @@ -3282,11 +3338,6 @@ public sealed class GameWindow : IDisposable private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; private string _activeToonKey = "default"; - private bool ToggleRetailWindow(string name) - { - return _retailUiRuntime?.ToggleWindow(name) ?? false; - } - private void SyncToolbarWindowButtons() { _retailUiRuntime?.SyncToolbarWindowButtons(); @@ -3369,12 +3420,6 @@ public sealed class GameWindow : IDisposable } } - private void OnUiDragReleasedOutside(object payload, int x, int y) - { - if (payload is AcDream.App.UI.ItemDragPayload itemPayload) - _selectionInteractions?.PlaceDraggedItem(itemPayload, x, y); - } - // L.0 follow-up: persisted-settings cache populated by // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, // not gated on DevToolsEnabled). The Settings PANEL construction @@ -3563,202 +3608,6 @@ public sealed class GameWindow : IDisposable && width > 0 && height > 0; } - // ── K.1b: dispatcher action handler ────────────────────────────────── - // - // SINGLE place where every game-side keyboard/mouse-button reaction - // lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp - // handlers are gone; everything now flows through InputDispatcher.Fired - // → here. New behaviors register a new InputAction in the enum + a - // case in this switch + a binding in KeyBindings. - - /// - /// K.1b — multicast subscriber on . - /// Handles every game-side reaction to a keyboard/mouse-button chord. - /// Per-frame held-state polling (movement WASD/Shift/Space) lives in - /// via ; - /// this method handles transitional Press/Release events only. - /// - private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode) - { - _inputDispatcher?.SetCombatScope(mode switch - { - AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat, - AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat, - AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat, - _ => null, - }); - } - - private void OnInputAction( - AcDream.UI.Abstractions.Input.InputAction action, - AcDream.UI.Abstractions.Input.ActivationType activation) - { - // Diagnostic — kept from K.1a; helpful for K.1c verification. - Console.WriteLine($"[input] {action} {activation}"); - - // RMB orbit and instant mouse-look remain the first accepted input - // transition. Their state is owned by the gameplay/pointer owners. - if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) - return; - - // Phase K.2 — MMB-hold instant mouse-look. Press hides the - // cursor + activates yaw drive; release restores. WantCapture - // edge handling lives in OnUpdate; only Press needs to read it - // for the initial gate (defense in depth — the dispatcher - // already filters on WantCaptureMouse in OnMouseDown). - // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every - // wheel tick. Press is the only activation type for wheel. - if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp - || action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown) - { - if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return; - HandleScrollAction(action); - return; - } - - // ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic - // combat before the movement command enters CommandInterpreter. This - // notification does not consume the input; the movement owner below - // still receives it normally. - // Retail attack actions consume their full transition stream: key-down - // starts the power build and key-up commits it. Handle them before the - // generic Press-only gate below. - if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true) - return; - - // Every other action fires on Press only (no Release / Hold side- - // effects in the K.1b set). Filter out non-Press activations early - // so subscribers that have Release-mode bindings don't accidentally - // re-fire. B.4b exception: DoubleClick must pass through so - // SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch. - if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press - && activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return; - - // Wave 4.1: one retained-UI delegation seam. The runtime routes only - // toolbar-owned semantic actions; every other action continues through - // the game switch below. Keyboard capture is already enforced at the - // InputDispatcher source, matching retail's stack-entry focus gate. - if (_retailUiRuntime?.HandleInputAction(action) == true) - return; - - if (_selectionInteractions?.HandleInputAction(action) == true) - return; - - // K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press - // Q to start, press Q again to stop. Pressing Backup / Stop / - // StrafeLeft / StrafeRight while autorun is active also cancels it - // — retail-faithful: any deliberate movement input wins. (Pressing - // Forward AGAIN does NOT cancel — retail's autorun stays active - // even when you press W; the two stack.) - if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true) - return; - - switch (action) - { - case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: - // Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it - // does not fire while the chat input holds focus. Null _uiHost = - // retail UI off (ACDREAM_RETAIL_UI unset) → no-op. - ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: - _devToolsFramePresenter?.ToggleDebugPanel(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires: - ToggleCollisionWires(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby: - DumpPlayerAndNearbyEntities(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay: - CycleTimeOfDay(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown: - AdjustActiveSensitivity(1f / 1.2f); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp: - AdjustActiveSensitivity(1.2f); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather: - CycleWeather(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode: - // K-fix3 (2026-04-26): proper round-trip when player has - // an active chase camera. ToggleFly() only swaps - // Fly↔Orbit, so a user who flew out of player mode used - // to land in Holtburg-orbit on toggle-back. With a chase - // camera available, prefer Fly→Chase / Chase→Fly so the - // user round-trips back to the same player view. - _playerModeController?.ToggleFlyOrChase(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode: - _playerModeController?.Toggle(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: - // K.2: Tab focuses the chat input. ChatPanel.FocusInput() - // sets a one-shot flag that emits SetKeyboardFocusHere on - // the next render. No-op when the devtools presenter is absent - // (offline / non-devtools build) — the dispatcher still - // logs the action via the [input] diagnostic above so the - // path is observable in either case. - _devToolsFramePresenter?.FocusChatInput(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel: - // K.3: F11 toggles the Settings panel. Null-safe vs. - // devtools-off / panel-not-registered — the [input] log - // line above still records the press regardless. - _devToolsFramePresenter?.ToggleSettingsPanel(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: - ToggleLiveCombatMode(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: - if (_itemInteractionController?.IsAnyTargetModeActive == true) - _itemInteractionController.CancelTargetMode(); - else if (_cameraController?.IsFlyMode == true) - _cameraController.ToggleFly(); // exit fly, release cursor - else if (_playerMode) - _playerModeController?.Exit(); - else - _window!.Close(); - break; - } - } - - private void ToggleLiveCombatMode() - { - AcDream.Core.Net.WorldSession? session = LiveSession; - if (_liveSessionHost?.IsInWorld != true || session is null) - return; - - IReadOnlyList orderedEquipment = - Objects.GetEquippedBy(_playerServerGuid); - var defaultMode = AcDream.Core.Combat.CombatInputPlanner - .GetDefaultCombatMode(orderedEquipment); - var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode( - Combat.CurrentMode, - defaultMode); - _itemInteractionController?.NotifyExplicitCombatModeRequest(); - session.SendChangeCombatMode(nextMode); - Combat.SetCombatMode(nextMode); - string text = $"Combat mode {nextMode}"; - Console.WriteLine($"combat: {text}"); - _debugVm?.AddToast(text); - } - /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs @@ -3843,87 +3692,6 @@ public sealed class GameWindow : IDisposable return unhydratable; } - /// - /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies - /// the currently-active mode's sensitivity (chase / fly / orbit) by the - /// given factor and clamps to [0.005, 3.0]. - /// - private void AdjustActiveSensitivity(float factor) - { - if (_cameraPointerInput is not { } pointer) - return; - string message = pointer.AdjustSensitivity(factor); - _debugVm?.AddToast(message); - } - - /// - /// K.1b: F3 dump handler extracted into a method. Same body as the - /// previous in-line F3 branch — prints the player's position + - /// nearby visible entities + nearby shadow physics objects. - /// - private void DumpPlayerAndNearbyEntities() - { - System.Numerics.Vector3 pos; - if (_playerMode && _playerController is not null) - pos = _playerController.Position; - else - { - System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); - pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); - } - int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); - int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); - Console.WriteLine( - $"=== F3 DEBUG DUMP ===\n" + - $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + - $" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" + - $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); - - var visibleNearby = new List(); - foreach (var e in _worldState.Entities) - { - float dx = e.Position.X - pos.X; - float dy = e.Position.Y - pos.Y; - if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); - } - Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); - foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) - { - float d = (e.Position - pos).Length(); - Console.WriteLine( - $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + - $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); - } - - var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); - foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - float dx = o.Position.X - pos.X; - float dy = o.Position.Y - pos.Y; - float d = MathF.Sqrt(dx * dx + dy * dy); - if (d < 15f) sorted.Add((o, d)); - } - sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); - Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); - foreach (var (o, d) in sorted.Take(12)) - { - Console.WriteLine( - $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + - $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); - } - } - - /// - /// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever - /// camera distance is current — chase camera distance in player mode, - /// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude - /// is fixed-step (the previous proportional scroll.Y was lost when we - /// moved scroll into the dispatcher, but the discrete step matches - /// retail wheel feel). - /// - private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) - => _cameraPointerInput?.HandleScroll(action); - private void OnClosing() { _hostQuiescence.StopAccepting(); @@ -3956,6 +3724,10 @@ public sealed class GameWindow : IDisposable // are already inert. new ResourceShutdownStage("input callback deactivation", [ + new("combat command slot", _liveCombatModeCommands.Deactivate), + new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate), + new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()), + new("gameplay actions", () => _gameplayInputActions?.Deactivate()), new("camera pointer", () => _cameraPointerInput?.Deactivate()), new("dispatcher", () => _inputDispatcher?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()), @@ -3991,6 +3763,28 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("input callback detach", [ + new("retained gameplay", () => + { + AcDream.App.Input.RetainedUiGameplayBinding? binding = + _retainedUiGameplayBinding; + if (binding is null) return; + binding.Dispose(); + if (!binding.IsDisposalComplete) + throw new InvalidOperationException( + "Retained gameplay callback removal remains pending."); + _retainedUiGameplayBinding = null; + }), + new("gameplay actions", () => + { + AcDream.App.Input.GameplayInputActionRouter? actions = + _gameplayInputActions; + if (actions is null) return; + actions.Dispose(); + if (!actions.IsDisposalComplete) + throw new InvalidOperationException( + "Gameplay action callback removal remains pending."); + _gameplayInputActions = null; + }), new("retained UI input", () => _uiHost?.DeactivateInput()), new("developer tools input", () => _devToolsInputContext?.Dispose()), new("camera pointer", () => @@ -4003,14 +3797,11 @@ public sealed class GameWindow : IDisposable throw new InvalidOperationException( "Camera pointer callback removal remains pending."); }), - new("combat input subscription", () => - Combat.CombatModeChanged -= SetInputCombatScope), new("dispatcher", () => { AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher = _inputDispatcher; if (dispatcher is null) return; - dispatcher.Fired -= OnInputAction; _movementInput.Unbind(dispatcher); _cameraInput.Unbind(dispatcher); dispatcher.Dispose(); diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index b0221418..1e752102 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -36,6 +36,14 @@ public enum CombatAttackAction High, } +/// +/// Retail's default-mode lookup returns the selected mode and, on the one +/// rejected held-item branch, the object used to format its user notice. +/// +public readonly record struct DefaultCombatModeDecision( + CombatMode Mode, + ClientObject? IncompatibleHeldItem); + /// /// Retail input-facing combat decisions. The heavyweight parts of the combat /// system remain server authoritative; this helper only maps UI intent to the @@ -92,6 +100,14 @@ public static class CombatInputPlanner /// object intersecting the requested location mask. /// public static CombatMode GetDefaultCombatMode( + IReadOnlyList orderedPlayerContents) => + GetDefaultCombatModeDecision(orderedPlayerContents).Mode; + + /// + /// Complete result of retail GetDefaultCombatMode, including the + /// incompatible Held object used by its exact failure notice. + /// + public static DefaultCombatModeDecision GetDefaultCombatModeDecision( IReadOnlyList orderedPlayerContents) { ArgumentNullException.ThrowIfNull(orderedPlayerContents); @@ -102,20 +118,22 @@ public static class CombatInputPlanner { // Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in // this primary weapon slot selects melee. - return weapon.CombatUse == 2 - ? CombatMode.Missile - : CombatMode.Melee; + return new( + weapon.CombatUse == 2 + ? CombatMode.Missile + : CombatMode.Melee, + IncompatibleHeldItem: null); } ClientObject? held = GetObjectAtLocation( orderedPlayerContents, EquipMask.Held); if (held is null) - return CombatMode.Melee; + return new(CombatMode.Melee, IncompatibleHeldItem: null); // The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster). return (held.Type & ItemType.Caster) != 0 - ? CombatMode.Magic - : CombatMode.NonCombat; + ? new(CombatMode.Magic, IncompatibleHeldItem: null) + : new(CombatMode.NonCombat, held); } private static ClientObject? GetObjectAtLocation( @@ -136,12 +154,13 @@ public static class CombatInputPlanner CombatMode currentMode, CombatMode defaultCombatMode = CombatMode.Melee) { - if ((currentMode & CombatMode.CombatCombat) != 0) + // ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0 compares the + // current value directly with NonCombat. It does not coerce a + // rejected/default NonCombat result to unarmed Melee. + if (currentMode != CombatMode.NonCombat) return CombatMode.NonCombat; - return (defaultCombatMode & CombatMode.CombatCombat) != 0 - ? defaultCombatMode - : CombatMode.Melee; + return defaultCombatMode; } public static bool SupportsTargetedAttack(CombatMode mode) => diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs new file mode 100644 index 00000000..39136863 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs @@ -0,0 +1,190 @@ +using AcDream.App.Combat; +using AcDream.Core.Combat; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.Combat; + +public sealed class LiveCombatModeCommandControllerTests +{ + [Fact] + public void OutsideWorld_IsCompleteNoOpBeforeEquipmentQuery() + { + var harness = new Harness { Authority = { IsInWorldValue = false } }; + + harness.Controller.Toggle(); + + Assert.Equal(0, harness.Equipment.QueryCount); + Assert.Empty(harness.Calls); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void PeaceWithBow_PreservesIntentSendLocalStateFeedbackOrder() + { + var harness = new Harness(); + harness.Equipment.Items.Add(new ClientObject + { + ObjectId = 1, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + Type = ItemType.MissileWeapon, + CombatUse = 2, + }); + harness.Combat.CombatModeChanged += mode => + harness.Calls.Add($"state:{mode}"); + + harness.Controller.Toggle(); + + Assert.Equal(1, harness.Equipment.QueryCount); + Assert.Equal(CombatMode.Missile, harness.Combat.CurrentMode); + Assert.Equal( + [ + "intent", + "send:Missile", + "state:Missile", + "log:combat: Combat mode Missile", + "toast:Combat mode Missile", + ], + harness.Calls); + } + + [Fact] + public void ActiveCombat_TogglesToPeaceRegardlessOfEquipmentDefault() + { + var harness = new Harness(); + harness.Combat.SetCombatMode(CombatMode.Magic); + + harness.Controller.Toggle(); + + Assert.Equal(0, harness.Equipment.QueryCount); + Assert.Equal([CombatMode.NonCombat], harness.Authority.Requests); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void PeaceWithIncompatibleHeldItem_PrintsRetailNoticeWithoutSending() + { + var harness = new Harness(); + harness.Equipment.Items.Add(new ClientObject + { + ObjectId = 1, + Name = "Torch", + CurrentlyEquippedLocation = EquipMask.Held, + Type = ItemType.Misc, + }); + + harness.Controller.Toggle(); + + Assert.Empty(harness.Authority.Requests); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + Assert.Equal( + [ + "intent", + "log:combat: You can't enter combat mode while wielding the Torch", + "system:You can't enter combat mode while wielding the Torch", + ], + harness.Calls); + } + + [Fact] + public void TransportFailure_LeavesLocalModeAndFeedbackUnpublished() + { + var harness = new Harness { Authority = { ThrowOnSend = true } }; + + Assert.Throws(harness.Controller.Toggle); + + Assert.Equal(["intent", "send:Melee"], harness.Calls); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void DeferredSlot_IsNoOpBeforeBindAndForwardsOneOwnerAfterBind() + { + var slot = new LiveCombatModeCommandSlot(); + var target = new FakeCommand(); + + slot.Toggle(); + slot.Bind(target); + slot.Toggle(); + slot.Bind(target); + + Assert.Equal(1, target.ToggleCount); + Assert.Throws( + () => slot.Bind(new FakeCommand())); + slot.Unbind(target); + slot.Toggle(); + Assert.Equal(1, target.ToggleCount); + + slot.Bind(target); + Action copied = slot.Toggle; + slot.Deactivate(); + copied(); + Assert.Equal(1, target.ToggleCount); + Assert.Throws(() => slot.Bind(target)); + } + + private sealed class Harness + { + public Harness() + { + Authority = new FakeAuthority(Calls); + Equipment = new FakeEquipment(); + Intent = new FakeIntent(Calls); + Controller = new LiveCombatModeCommandController( + Authority, + Equipment, + Combat, + Intent, + line => Calls.Add($"log:{line}"), + line => Calls.Add($"toast:{line}"), + line => Calls.Add($"system:{line}")); + } + + public List Calls { get; } = []; + public FakeAuthority Authority { get; } + public FakeEquipment Equipment { get; } + public CombatState Combat { get; } = new(); + public FakeIntent Intent { get; } + public LiveCombatModeCommandController Controller { get; } + } + + private sealed class FakeAuthority(List calls) + : ILiveCombatModeAuthority + { + public bool IsInWorldValue { get; set; } = true; + public bool IsInWorld => IsInWorldValue; + public bool ThrowOnSend { get; set; } + public List Requests { get; } = []; + + public void SendChangeCombatMode(CombatMode mode) + { + calls.Add($"send:{mode}"); + if (ThrowOnSend) + throw new InvalidOperationException("send"); + Requests.Add(mode); + } + } + + private sealed class FakeEquipment : ICombatEquipmentSource + { + public List Items { get; } = []; + public int QueryCount { get; private set; } + + public IReadOnlyList GetOrderedEquipment() + { + QueryCount++; + return Items; + } + } + + private sealed class FakeIntent(List calls) + : IExplicitCombatModeIntentSink + { + public void NotifyExplicitCombatModeRequest() => calls.Add("intent"); + } + + private sealed class FakeCommand : ILiveCombatModeCommand + { + public int ToggleCount { get; private set; } + public void Toggle() => ToggleCount++; + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs new file mode 100644 index 00000000..6c10f259 --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs @@ -0,0 +1,185 @@ +using System.Numerics; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Diagnostics; + +[Collection(AcDream.App.Tests.World.WorldEnvironmentControllerCollection.Name)] +public sealed class RuntimeDiagnosticCommandControllerTests +{ + [Fact] + public void Handle_RoutesCanonicalEnvironmentSceneAndDumpOwners() + { + var source = new FakeNearbySource(); + var logs = new List(); + var toasts = new List(); + var scene = new WorldSceneDebugState(); + var controller = new RuntimeDiagnosticCommandController( + new WorldEnvironmentController(), + scene, + pointer: null, + new NearbyWorldDiagnosticDumper(source, logs.Add), + toasts.Add); + + Assert.True(controller.Handle(InputAction.AcdreamToggleCollisionWires)); + Assert.True(scene.CollisionWireframesVisible); + Assert.True(controller.Handle(InputAction.AcdreamCycleTimeOfDay)); + Assert.True(controller.Handle(InputAction.AcdreamCycleWeather)); + Assert.True(controller.Handle(InputAction.AcdreamDumpNearby)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp)); + Assert.False(controller.Handle(InputAction.MovementForward)); + + Assert.Contains("Collision wireframes ON", toasts); + Assert.Contains("Time override = 0.00", toasts); + Assert.Contains("Weather = Overcast", toasts); + Assert.Equal(3, toasts.Count); + Assert.Contains(logs, line => line.StartsWith( + "=== F3 DEBUG DUMP ===", StringComparison.Ordinal)); + } + + [Fact] + public void NearbyDump_PreservesLandblockCoordinatesRangeAndDistanceOrder() + { + var source = new FakeNearbySource + { + ObserverPositionValue = new Vector3(200f, -1f, 3f), + LiveCenterXValue = 10, + LiveCenterYValue = 20, + TotalShadowObjectsValue = 4, + }; + source.Entities.Add(Entity(2, new Vector3(203f, -1f, 3f))); + source.Entities.Add(Entity(1, new Vector3(201f, -1f, 3f))); + source.Entities.Add(Entity(3, new Vector3(216f, -1f, 3f))); + source.Shadows.Add(new ShadowEntry( + EntityId: 5, + GfxObjId: 6, + Position: new Vector3(202f, -1f, 3f), + Rotation: Quaternion.Identity, + Radius: 0.5f, + CollisionType: ShadowCollisionType.Sphere)); + var lines = new List(); + + new NearbyWorldDiagnosticDumper(source, lines.Add).Dump(); + + Assert.Contains("landblock=0x0B13FFFF local=(8.00,191.00)", lines[0]); + Assert.Contains("total shadow objects: 4", lines[0]); + Assert.Equal(" VISIBLE entities within 15m: 2", lines[1]); + Assert.Contains("id=0x00000001", lines[2]); + Assert.Contains("id=0x00000002", lines[3]); + Assert.Equal(" SHADOW objects within 15m: 1", lines[4]); + Assert.Contains("id=0x00000005", lines[5]); + } + + [Fact] + public void DeferredSlot_ClaimsKnownActionsBeforeBindAndForwardsAfterBind() + { + var slot = new RuntimeDiagnosticCommandSlot(); + var target = new FakeCommands(); + + Assert.True(slot.Handle(InputAction.AcdreamDumpNearby)); + Assert.False(slot.Handle(InputAction.MovementForward)); + slot.CycleTimeOfDay(); + + slot.Bind(target); + slot.Bind(target); + Assert.True(slot.Handle(InputAction.AcdreamDumpNearby)); + slot.CycleTimeOfDay(); + slot.CycleWeather(); + slot.ToggleCollisionWireframes(); + + Assert.Equal(4, target.CallCount); + Assert.Throws( + () => slot.Bind(new FakeCommands())); + slot.Unbind(target); + slot.CycleWeather(); + Assert.Equal(4, target.CallCount); + + slot.Bind(target); + Action copied = slot.CycleWeather; + slot.Deactivate(); + copied(); + Assert.Equal(4, target.CallCount); + Assert.Throws(() => slot.Bind(target)); + } + + [Fact] + public void NullToast_DoesNotSuppressDiagnosticStateChanges() + { + var environment = new WorldEnvironmentController(); + var scene = new WorldSceneDebugState(); + var sensitivity = new FakeSensitivity(); + var controller = new RuntimeDiagnosticCommandController( + environment, + scene, + sensitivity, + new NearbyWorldDiagnosticDumper(new FakeNearbySource()), + toast: null); + + controller.CycleTimeOfDay(); + controller.CycleWeather(); + controller.ToggleCollisionWireframes(); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp)); + + Assert.Equal(0f, environment.DayFraction); + Assert.Equal(WeatherKind.Overcast, environment.Weather.Kind); + Assert.True(scene.CollisionWireframesVisible); + Assert.Equal(2, sensitivity.AdjustCount); + } + + private static WorldEntity Entity(uint id, Vector3 position) => new() + { + Id = id, + SourceGfxObjOrSetupId = id + 100, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + + private sealed class FakeNearbySource : INearbyWorldDiagnosticSource + { + public Vector3 ObserverPositionValue { get; set; } + public int LiveCenterXValue { get; set; } + public int LiveCenterYValue { get; set; } + public int TotalShadowObjectsValue { get; set; } + public List Entities { get; } = []; + public List Shadows { get; } = []; + + public Vector3 ObserverPosition => ObserverPositionValue; + public int LiveCenterX => LiveCenterXValue; + public int LiveCenterY => LiveCenterYValue; + public int TotalShadowObjects => TotalShadowObjectsValue; + public IReadOnlyList WorldEntities => Entities; + public IEnumerable ShadowEntries => Shadows; + } + + private sealed class FakeCommands : IRuntimeDiagnosticCommands + { + public int CallCount { get; private set; } + public bool Handle(InputAction action) + { + CallCount++; + return true; + } + public void CycleTimeOfDay() => CallCount++; + public void CycleWeather() => CallCount++; + public void ToggleCollisionWireframes() => CallCount++; + } + + private sealed class FakeSensitivity : IPointerSensitivityCommands + { + public int AdjustCount { get; private set; } + + public string AdjustSensitivity(float factor) + { + AdjustCount++; + return $"sensitivity {factor}"; + } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs new file mode 100644 index 00000000..a840d884 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs @@ -0,0 +1,328 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputActionRouterTests +{ + [Theory] + [InlineData("pointer", "pointer")] + [InlineData("combat", "pointer,combat")] + [InlineData("retained", "pointer,combat,retained")] + [InlineData("selection", "pointer,combat,retained,selection")] + [InlineData("movement", "pointer,combat,retained,selection,movement")] + [InlineData("command", "pointer,combat,retained,selection,movement,command")] + public void Press_PreservesFrozenPriorityAndStopsAtConsumer( + string consumeAt, + string expectedCsv) + { + var harness = new Harness(consumeAt); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.AcdreamToggleDebugPanel, + ActivationType.Press); + + Assert.Equal(expectedCsv.Split(','), harness.Targets.Calls); + } + + [Fact] + public void Scroll_IsBetweenPointerAndCombat_AndOnlyPressMovesIt() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise(InputAction.ScrollUp, ActivationType.Release); + Assert.Equal(["pointer"], harness.Targets.Calls); + + harness.Targets.Calls.Clear(); + harness.Actions.Raise(InputAction.ScrollDown, ActivationType.Press); + Assert.Equal(["pointer", "scroll:ScrollDown"], harness.Targets.Calls); + } + + [Fact] + public void Release_ReachesCombatThenStopsAtPressGate() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.CombatHighAttack, + ActivationType.Release); + + Assert.Equal(["pointer", "combat"], harness.Targets.Calls); + } + + [Fact] + public void DoubleClick_PassesGateToRetainedSelectionMovementAndCommands() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.SelectDblLeft, + ActivationType.DoubleClick); + + Assert.Equal( + ["pointer", "combat", "retained", "selection", "movement", "command"], + harness.Targets.Calls); + } + + [Fact] + public void Attach_SeedsAndTracksCombatScopes() + { + var harness = new Harness(); + harness.Combat.CurrentModeValue = CombatMode.Missile; + harness.Router.Attach(); + + harness.Combat.Raise(CombatMode.Magic); + harness.Combat.Raise(CombatMode.NonCombat); + + Assert.Equal( + [InputScope.MissileCombat, InputScope.MagicCombat, null], + harness.Actions.Scopes); + } + + [Theory] + [InlineData(0, "remove-actions")] + [InlineData(1, "remove-combat,remove-actions")] + public void AttachFailureAfterSideEffect_RollsBackExactPrefixInReverse( + int failingEdge, + string expectedRemovalCsv) + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + ThrowAfterAdd = failingEdge == 0, + }; + var combat = new FakeCombatSurface(calls) + { + ThrowAfterAdd = failingEdge == 1, + }; + var router = Create(actions, combat, new FakeTargets()); + + Assert.Throws(router.Attach); + + Assert.Equal(expectedRemovalCsv.Split(','), calls.Where( + call => call.StartsWith("remove", StringComparison.Ordinal))); + Assert.True(router.IsDisposalComplete); + } + + [Fact] + public void ReentrantDeliveryDuringAdd_CannotEnterPartialRouter() + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + RaiseDuringAdd = true, + }; + var targets = new FakeTargets(); + var router = Create(actions, new FakeCombatSurface(calls), targets); + + router.Attach(); + + Assert.Empty(targets.Calls); + actions.Raise(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); + Assert.NotEmpty(targets.Calls); + } + + [Fact] + public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose() + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + ThrowAfterAdd = true, + RemoveFailures = int.MaxValue, + }; + var router = Create( + actions, + new FakeCombatSurface(calls), + new FakeTargets()); + + Assert.Throws(router.Attach); + Assert.False(router.IsDisposalComplete); + + actions.RemoveFailures = 0; + router.Dispose(); + + Assert.True(router.IsDisposalComplete); + Assert.Null(actions.Callback); + } + + [Fact] + public void Dispose_RetriesOnlyFailedRemovalAndNeverReplaysSuccess() + { + var calls = new List(); + var actions = new FakeActionSurface(calls); + var combat = new FakeCombatSurface(calls) + { + RemoveFailures = int.MaxValue, + }; + var router = Create(actions, combat, new FakeTargets()); + router.Attach(); + + Assert.Throws(router.Dispose); + Assert.False(router.IsDisposalComplete); + + combat.RemoveFailures = 0; + router.Dispose(); + + Assert.True(router.IsDisposalComplete); + Assert.Equal(4, calls.Count(call => call == "remove-combat")); + Assert.Equal(1, calls.Count(call => call == "remove-actions")); + } + + [Fact] + public void Deactivate_MakesCopiedCallbacksSilentBeforePhysicalDetach() + { + var harness = new Harness(); + harness.Router.Attach(); + Action copiedAction = + harness.Actions.Callback!; + Action copiedCombat = harness.Combat.Callback!; + + harness.Router.Deactivate(); + copiedAction(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); + copiedCombat(CombatMode.Melee); + + Assert.Empty(harness.Targets.Calls); + Assert.Equal([null], harness.Actions.Scopes); + } + + [Fact] + public void DisposeBeforeAttach_IsTerminalAndCannotResurrectCallbacks() + { + var harness = new Harness(); + + harness.Router.Dispose(); + + Assert.True(harness.Router.IsDisposalComplete); + Assert.Throws(harness.Router.Attach); + Assert.Null(harness.Actions.Callback); + Assert.Null(harness.Combat.Callback); + } + + private static GameplayInputActionRouter Create( + FakeActionSurface actions, + FakeCombatSurface combat, + FakeTargets targets) => + new(actions, combat, targets, new HostQuiescenceGate()); + + private sealed class Harness + { + public Harness(string? consumeAt = null) + { + var calls = new List(); + Actions = new FakeActionSurface(calls); + Combat = new FakeCombatSurface(calls); + Targets = new FakeTargets(consumeAt); + Router = Create(Actions, Combat, Targets); + } + + public FakeActionSurface Actions { get; } + public FakeCombatSurface Combat { get; } + public FakeTargets Targets { get; } + public GameplayInputActionRouter Router { get; } + } + + private sealed class FakeActionSurface(List calls) + : IGameplayInputActionSurface + { + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public bool RaiseDuringAdd { get; set; } + public int RemoveFailures { get; set; } + public List Scopes { get; } = []; + + public void AddFired(Action callback) + { + calls.Add("add-actions"); + Callback = callback; + if (RaiseDuringAdd) + { + callback( + InputAction.AcdreamToggleDebugPanel, + ActivationType.Press); + } + if (ThrowAfterAdd) + throw new InvalidOperationException("actions add"); + } + + public void RemoveFired(Action callback) + { + calls.Add("remove-actions"); + if (RemoveFailures-- > 0) + throw new InvalidOperationException("actions remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void SetCombatScope(InputScope? scope) => Scopes.Add(scope); + + public void Raise(InputAction action, ActivationType activation) => + Callback?.Invoke(action, activation); + } + + private sealed class FakeCombatSurface(List calls) + : ICombatModeEventSurface + { + public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat; + public CombatMode CurrentMode => CurrentModeValue; + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public int RemoveFailures { get; set; } + + public void AddChanged(Action callback) + { + calls.Add("add-combat"); + Callback = callback; + if (ThrowAfterAdd) + throw new InvalidOperationException("combat add"); + } + + public void RemoveChanged(Action callback) + { + calls.Add("remove-combat"); + if (RemoveFailures-- > 0) + throw new InvalidOperationException("combat remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void Raise(CombatMode mode) => Callback?.Invoke(mode); + } + + private sealed class FakeTargets(string? consumeAt = null) + : IGameplayInputPriorityTargets + { + public List Calls { get; } = []; + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + Record("pointer"); + + public void HandleScroll(InputAction action) => Calls.Add($"scroll:{action}"); + + public bool HandleCombatAction(InputAction action, ActivationType activation) => + Record("combat"); + + public bool HandleRetainedUiAction(InputAction action) => + Record("retained"); + + public bool HandleSelectionAction(InputAction action) => + Record("selection"); + + public bool HandlePressedMovementAction(InputAction action) => + Record("movement"); + + public void HandleCommand(InputAction action) => Calls.Add("command"); + + private bool Record(string name) + { + Calls.Add(name); + return consumeAt == name; + } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs new file mode 100644 index 00000000..5bc9543f --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -0,0 +1,180 @@ +using AcDream.App.Combat; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputCommandControllerTests +{ + [Theory] + [InlineData(InputAction.ToggleInventoryPanel, "inventory")] + [InlineData(InputAction.AcdreamToggleDebugPanel, "debug")] + [InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")] + [InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")] + [InlineData(InputAction.ToggleChatEntry, "chat")] + [InlineData(InputAction.ToggleOptionsPanel, "settings")] + [InlineData(InputAction.CombatToggleCombat, "combat")] + public void RecognizedCommand_RoutesToTypedOwner( + InputAction action, + string expected) + { + var harness = new Harness(); + + bool handled = harness.Controller.Handle(action); + + Assert.True(handled); + Assert.Equal([expected], harness.Calls); + } + + [Fact] + public void DiagnosticCommand_PrecedesRemainingCommandSwitch() + { + var harness = new Harness(); + harness.Diagnostics.HandledAction = InputAction.AcdreamToggleDebugPanel; + + bool handled = harness.Controller.Handle( + InputAction.AcdreamToggleDebugPanel); + + Assert.True(handled); + Assert.Equal(["diagnostic"], harness.Calls); + } + + [Fact] + public void UnknownCommand_IsNotClaimed() + { + var harness = new Harness(); + + bool handled = harness.Controller.Handle(InputAction.MovementForward); + + Assert.False(handled); + Assert.Empty(harness.Calls); + } + + [Theory] + [InlineData(true, true, true, "cancel-target")] + [InlineData(false, true, true, "exit-fly")] + [InlineData(false, false, true, "exit-player")] + [InlineData(false, false, false, "close")] + public void Escape_PreservesTargetFlyPlayerWindowPriority( + bool targetMode, + bool flyMode, + bool playerMode, + string expected) + { + var harness = new Harness + { + TargetMode = { IsActive = targetMode }, + Camera = { IsFly = flyMode }, + Player = { IsPlayer = playerMode }, + }; + + bool handled = harness.Controller.Handle(InputAction.EscapeKey); + + Assert.True(handled); + Assert.Equal([expected], harness.Calls); + } + + private sealed class Harness + { + public Harness() + { + Retained = new FakeRetained(Calls); + DevTools = new FakeDevTools(Calls); + Diagnostics = new FakeDiagnostics(Calls); + Player = new FakePlayerMode(Calls); + TargetMode = new FakeTargetMode(Calls); + Camera = new FakeCamera(Calls); + Combat = new FakeCombat(Calls); + Window = new FakeWindow(Calls); + Controller = new GameplayInputCommandController( + Retained, + DevTools, + Diagnostics, + Player, + TargetMode, + Camera, + Combat, + Window); + } + + public List Calls { get; } = []; + public FakeRetained Retained { get; } + public FakeDevTools DevTools { get; } + public FakeDiagnostics Diagnostics { get; } + public FakePlayerMode Player { get; } + public FakeTargetMode TargetMode { get; } + public FakeCamera Camera { get; } + public FakeCombat Combat { get; } + public FakeWindow Window { get; } + public GameplayInputCommandController Controller { get; } + } + + private sealed class FakeRetained(List calls) + : IRetainedGameplayWindowCommands + { + public void ToggleInventory() => calls.Add("inventory"); + } + + private sealed class FakeDevTools(List calls) + : IDevToolsGameplayCommands + { + public void ToggleDebugPanel() => calls.Add("debug"); + public void FocusChatInput() => calls.Add("chat"); + public void ToggleSettingsPanel() => calls.Add("settings"); + } + + private sealed class FakeDiagnostics(List calls) + : IRuntimeDiagnosticCommands + { + public InputAction? HandledAction { get; set; } + + public bool Handle(InputAction action) + { + if (action != HandledAction) + return false; + calls.Add("diagnostic"); + return true; + } + + public void CycleTimeOfDay() => calls.Add("time"); + public void CycleWeather() => calls.Add("weather"); + public void ToggleCollisionWireframes() => calls.Add("collision"); + } + + private sealed class FakePlayerMode(List calls) + : IPlayerModeGameplayCommands + { + public bool IsPlayer { get; set; } + public bool IsPlayerMode => IsPlayer; + public void ToggleFlyOrChase() => calls.Add("fly-or-chase"); + public void TogglePlayerMode() => calls.Add("player-mode"); + public void ExitPlayerMode() => calls.Add("exit-player"); + } + + private sealed class FakeTargetMode(List calls) + : IItemTargetModeCommands + { + public bool IsActive { get; set; } + public bool IsAnyTargetModeActive => IsActive; + public void CancelTargetMode() => calls.Add("cancel-target"); + } + + private sealed class FakeCamera(List calls) + : IGameplayCameraModeCommands + { + public bool IsFly { get; set; } + public bool IsFlyMode => IsFly; + public void ExitFlyMode() => calls.Add("exit-fly"); + } + + private sealed class FakeCombat(List calls) : ILiveCombatModeCommand + { + public void Toggle() => calls.Add("combat"); + } + + private sealed class FakeWindow(List calls) : IGameplayWindowCommands + { + public void Close() => calls.Add("close"); + } +} diff --git a/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs b/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs new file mode 100644 index 00000000..0344d702 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs @@ -0,0 +1,158 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Tests.Input; + +public sealed class RetainedUiGameplayBindingTests +{ + [Fact] + public void AttachedBinding_RoutesOnlyItemPayloads() + { + var surface = new Surface(); + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + binding.Attach(); + + surface.Raise("not an item", 1, 2); + surface.Raise(Item(42), 7, 9); + + Assert.Equal([(42u, 7, 9)], calls); + } + + [Fact] + public void AddAfterSideEffectFailure_RollsBackExactCallback() + { + var surface = new Surface { ThrowAfterAdd = true }; + var binding = Create(surface, []); + + Assert.Throws(binding.Attach); + + Assert.True(binding.IsDisposalComplete); + Assert.Null(surface.Callback); + Assert.Equal(1, surface.RemoveCalls); + } + + [Fact] + public void ReentrantDeliveryDuringAdd_CannotEnterPartialBinding() + { + var surface = new Surface { RaiseDuringAdd = true }; + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + + binding.Attach(); + Assert.Empty(calls); + surface.Raise(Item(2), 3, 4); + Assert.Equal([(2u, 3, 4)], calls); + } + + [Fact] + public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose() + { + var surface = new Surface + { + ThrowAfterAdd = true, + RemoveFailures = int.MaxValue, + }; + var binding = Create(surface, []); + + Assert.Throws(binding.Attach); + Assert.False(binding.IsDisposalComplete); + + surface.RemoveFailures = 0; + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Null(surface.Callback); + } + + [Fact] + public void Deactivate_MakesCopiedCallbackSilentBeforePhysicalDetach() + { + var surface = new Surface(); + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + binding.Attach(); + Action copied = surface.Callback!; + + binding.Deactivate(); + copied(Item(8), 1, 1); + + Assert.Empty(calls); + Assert.False(binding.IsDisposalComplete); + } + + [Fact] + public void Dispose_RetriesFailedRemovalWithoutReattaching() + { + var surface = new Surface { RemoveFailures = int.MaxValue }; + var binding = Create(surface, []); + binding.Attach(); + + Assert.Throws(binding.Dispose); + Assert.False(binding.IsDisposalComplete); + + surface.RemoveFailures = 0; + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Equal(1, surface.AddCalls); + Assert.Equal(3, surface.RemoveCalls); + } + + [Fact] + public void DisposeBeforeAttach_IsTerminal() + { + var surface = new Surface(); + var binding = Create(surface, []); + + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Throws(binding.Attach); + Assert.Null(surface.Callback); + } + + private static RetainedUiGameplayBinding Create( + Surface surface, + List<(uint Id, int X, int Y)> calls) => + new( + surface, + (item, x, y) => calls.Add((item.ObjId, x, y)), + new HostQuiescenceGate()); + + private static ItemDragPayload Item(uint id) => + new(id, ItemDragSource.Inventory, 0, SourceCell: null!); + + private sealed class Surface : IRetainedUiDragReleaseSurface + { + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public bool RaiseDuringAdd { get; set; } + public int RemoveFailures { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + + public void AddReleasedOutside(Action callback) + { + AddCalls++; + Callback = callback; + if (RaiseDuringAdd) + callback(Item(1), 1, 1); + if (ThrowAfterAdd) + throw new InvalidOperationException("add"); + } + + public void RemoveReleasedOutside(Action callback) + { + RemoveCalls++; + if (RemoveFailures-- > 0) + throw new InvalidOperationException("remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void Raise(object payload, int x, int y) => + Callback?.Invoke(payload, x, y); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 54f050f9..42c200a6 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -62,7 +62,6 @@ public sealed class GameWindowSlice8BoundaryTests "_inputDispatcher.Attach();", "_movementInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);", - "_inputDispatcher.Fired += OnInputAction;", "_cameraController = new CameraController(orbit, fly);", "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput.AttachRaw();", @@ -73,9 +72,17 @@ public sealed class GameWindowSlice8BoundaryTests "_uiHost.WireKeyboard(kb)", "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", + "_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", + "_retainedUiGameplayBinding =", + "AcDream.App.Input.RetainedUiGameplayBinding.Create(", + "_retainedUiGameplayBinding.Attach();", "_renderFrameOrchestrator =", "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", "_liveSessionHost = CreateLiveSessionHost();", + "_liveCombatModeCommands.Bind(combatCommand);", + "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);", + "_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(", + "_gameplayInputActions.Attach();", "_liveSessionHost.Start(_options)"); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal); @@ -128,33 +135,53 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal); Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal); - string cycleTime = MethodBody( - "private void CycleTimeOfDay()", - "private void CycleWeather()"); + Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal); AssertAppearsInOrder( - cycleTime, - "_worldEnvironment.CycleTimeOfDay();", - "_debugVm?.AddToast(message);"); + load, + "_debugVm.CycleTimeOfDay =", + "_runtimeDiagnosticCommands.CycleTimeOfDay;", + "new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(", + "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);"); } [Fact] - public void InputAction_PreservesRetailAcceptedPriority() + public void InputAction_IsOneTypedOwnerHandoff() { - string body = MethodBody( - "private void OnInputAction(", - "private void ToggleLiveCombatMode()"); + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = MethodBody( + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void ToggleLiveCombatMode()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void OnUiDragReleasedOutside(", source, StringComparison.Ordinal); + Assert.DoesNotContain("DragReleasedOutsideUi +=", source, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences( + load, + "AcDream.App.Input.GameplayInputActionRouter.Create(")); + Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();")); + Assert.Equal(1, CountOccurrences( + load, + "AcDream.App.Input.RetainedUiGameplayBinding.Create(")); + Assert.Equal(1, CountOccurrences( + load, + "_retainedUiGameplayBinding.Attach();")); AssertAppearsInOrder( - body, - "HandlePointerAction(action, activation)", - "action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp", - "HandleScrollAction(action);", - "HandleCombatAction(action, activation)", - "activation != AcDream.UI.Abstractions.Input.ActivationType.Press", - "_retailUiRuntime?.HandleInputAction(action)", - "_selectionInteractions?.HandleInputAction(action)", - "HandlePressedMovementAction(action)", - "switch (action)"); + shutdown, + "_liveCombatModeCommands.Deactivate", + "_runtimeDiagnosticCommands.Deactivate", + "_retainedUiGameplayBinding?.Deactivate()", + "_gameplayInputActions?.Deactivate()", + "new ResourceShutdownStage(\"session lifetime\"", + "binding.Dispose();", + "_retainedUiGameplayBinding = null;", + "actions.Dispose();", + "_gameplayInputActions = null;"); } [Fact] @@ -265,10 +292,16 @@ public sealed class GameWindowSlice8BoundaryTests "_windowCallbacks = null;"); AssertAppearsInOrder( manifest, + "new(\"combat command slot\", _liveCombatModeCommands.Deactivate)", + "new(\"diagnostic command slot\", _runtimeDiagnosticCommands.Deactivate)", + "new(\"retained gameplay\", () => _retainedUiGameplayBinding?.Deactivate())", + "new(\"gameplay actions\", () => _gameplayInputActions?.Deactivate())", "new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())", "new ResourceShutdownStage(\"session lifetime\"", "controller.Dispose();", "new ResourceShutdownStage(\"input callback detach\"", + "binding.Dispose();", + "actions.Dispose();", "pointer.Dispose();", "new ResourceShutdownStage(\"session dependents\"", "pointer.ReleaseMouseLookAfterSessionRetirement();", diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs index afd8c11f..0b0d834f 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs @@ -44,9 +44,11 @@ public sealed class CombatInputPlannerTests { var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0); - Assert.Equal( - CombatMode.NonCombat, - CombatInputPlanner.GetDefaultCombatMode([held])); + DefaultCombatModeDecision decision = + CombatInputPlanner.GetDefaultCombatModeDecision([held]); + + Assert.Equal(CombatMode.NonCombat, decision.Mode); + Assert.Same(held, decision.IncompatibleHeldItem); } [Fact] @@ -64,6 +66,9 @@ public sealed class CombatInputPlannerTests Assert.Equal( CombatMode.Missile, CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile)); + Assert.Equal( + CombatMode.NonCombat, + CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.NonCombat)); } [Fact] @@ -71,6 +76,7 @@ public sealed class CombatInputPlannerTests { Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee)); Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic)); + Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Undef)); } [Theory]