refactor(input): own gameplay action routing
Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
8b8afeefa3
commit
4eae9b4f5a
25 changed files with 2608 additions and 418 deletions
17
AGENTS.md
17
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,
|
||||
|
|
|
|||
17
CLAUDE.md
17
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
|
|
@ -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<ClientObject> 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<ClientObject> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the input-facing combat-mode command. The choice itself remains the
|
||||
/// retail port in <see cref="CombatInputPlanner"/>:
|
||||
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
|
||||
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
|
||||
/// authority; the local state update preserves the accepted immediate toolbar
|
||||
/// response while the server confirmation travels through the normal route.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
||||
{
|
||||
private readonly ILiveCombatModeAuthority _authority;
|
||||
private readonly ICombatEquipmentSource _equipment;
|
||||
private readonly CombatState _combat;
|
||||
private readonly IExplicitCombatModeIntentSink _itemIntent;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string> _systemMessage;
|
||||
|
||||
public LiveCombatModeCommandController(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
CombatState combat,
|
||||
IExplicitCombatModeIntentSink itemIntent,
|
||||
Action<string>? log = null,
|
||||
Action<string>? toast = null,
|
||||
Action<string>? 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<WorldEntity> WorldEntities { get; }
|
||||
|
||||
IEnumerable<ShadowEntry> ShadowEntries { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view over canonical runtime owners. It does not cache the current
|
||||
/// player, camera, entity list, origin, or shadow registry.
|
||||
/// </summary>
|
||||
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<WorldEntity> WorldEntities => _world.Entities;
|
||||
|
||||
public IEnumerable<ShadowEntry> ShadowEntries =>
|
||||
_physics.ShadowObjects.AllEntriesForDebug();
|
||||
}
|
||||
|
||||
internal sealed class NearbyWorldDiagnosticDumper
|
||||
{
|
||||
private const float NearbyDistance = 15f;
|
||||
private readonly INearbyWorldDiagnosticSource _source;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
public NearbyWorldDiagnosticDumper(
|
||||
INearbyWorldDiagnosticSource source,
|
||||
Action<string>? 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<WorldEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands
|
||||
{
|
||||
private readonly WorldEnvironmentController _environment;
|
||||
private readonly WorldSceneDebugState _sceneDebug;
|
||||
private readonly IPointerSensitivityCommands? _pointer;
|
||||
private readonly NearbyWorldDiagnosticDumper _nearby;
|
||||
private readonly Action<string>? _toast;
|
||||
|
||||
public RuntimeDiagnosticCommandController(
|
||||
WorldEnvironmentController environment,
|
||||
WorldSceneDebugState sceneDebug,
|
||||
IPointerSensitivityCommands? pointer,
|
||||
NearbyWorldDiagnosticDumper nearby,
|
||||
Action<string>? 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;
|
||||
}
|
||||
|
|
@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
|||
}
|
||||
}
|
||||
|
||||
internal interface IPointerSensitivityCommands
|
||||
{
|
||||
string AdjustSensitivity(float factor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController : IDisposable
|
||||
internal sealed class CameraPointerInputController
|
||||
: IDisposable, IPointerSensitivityCommands
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
|
|
|
|||
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
|
|
@ -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<InputAction, ActivationType> callback);
|
||||
|
||||
void RemoveFired(Action<InputAction, ActivationType> 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<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired += callback;
|
||||
|
||||
public void RemoveFired(Action<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired -= callback;
|
||||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
{
|
||||
CombatMode CurrentMode { get; }
|
||||
|
||||
void AddChanged(Action<CombatMode> callback);
|
||||
|
||||
void RemoveChanged(Action<CombatMode> 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<CombatMode> callback) =>
|
||||
_combat.CombatModeChanged += callback;
|
||||
|
||||
public void RemoveChanged(Action<CombatMode> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed adapter over canonical gameplay owners. Optional presentation owners
|
||||
/// remain no-ops when their stack is disabled; no state is mirrored here.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole gameplay subscriber to <see cref="InputDispatcher.Fired"/>. 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.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputActionRouter : IDisposable
|
||||
{
|
||||
private readonly IGameplayInputActionSurface _actions;
|
||||
private readonly ICombatModeEventSurface _combat;
|
||||
private readonly IGameplayInputPriorityTargets _targets;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<InputAction, ActivationType> _fired;
|
||||
private readonly Action<CombatMode> _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<string>? 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<string>? 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;
|
||||
}
|
||||
}
|
||||
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IRetainedUiDragReleaseSurface
|
||||
{
|
||||
void AddReleasedOutside(Action<object, int, int> callback);
|
||||
|
||||
void RemoveReleasedOutside(Action<object, int, int> callback);
|
||||
}
|
||||
|
||||
internal sealed class UiRootDragReleaseSurface(UiRoot root)
|
||||
: IRetainedUiDragReleaseSurface
|
||||
{
|
||||
private readonly UiRoot _root = root
|
||||
?? throw new ArgumentNullException(nameof(root));
|
||||
|
||||
public void AddReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi += callback;
|
||||
|
||||
public void RemoveReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi -= callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class RetainedUiGameplayBinding : IDisposable
|
||||
{
|
||||
private readonly IRetainedUiDragReleaseSurface _surface;
|
||||
private readonly Action<ItemDragPayload, int, int> _placeDraggedItem;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<object, int, int> _releasedOutside;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedUiGameplayBinding(
|
||||
IRetainedUiDragReleaseSurface surface,
|
||||
Action<ItemDragPayload, int, int> 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<ItemDragPayload, int, int> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>? 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void CycleTimeOfDay()
|
||||
{
|
||||
string message = _worldEnvironment.CycleTimeOfDay();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cycle the weather kind. Same body as the old F10 keybind handler.
|
||||
/// </summary>
|
||||
private void CycleWeather()
|
||||
{
|
||||
string message = _worldEnvironment.CycleWeather();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle the collision-wires debug renderer. Same body as the old
|
||||
/// F2 keybind handler.
|
||||
/// </summary>
|
||||
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.
|
||||
|
||||
/// <summary>
|
||||
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
|
||||
/// Handles every game-side reaction to a keyboard/mouse-button chord.
|
||||
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
|
||||
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
|
||||
/// this method handles transitional Press/Release events only.
|
||||
/// </summary>
|
||||
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<AcDream.Core.Items.ClientObject> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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].
|
||||
/// </summary>
|
||||
private void AdjustActiveSensitivity(float factor)
|
||||
{
|
||||
if (_cameraPointerInput is not { } pointer)
|
||||
return;
|
||||
string message = pointer.AdjustSensitivity(factor);
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AcDream.Core.World.WorldEntity>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ public enum CombatAttackAction
|
|||
High,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct DefaultCombatModeDecision(
|
||||
CombatMode Mode,
|
||||
ClientObject? IncompatibleHeldItem);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static CombatMode GetDefaultCombatMode(
|
||||
IReadOnlyList<ClientObject> orderedPlayerContents) =>
|
||||
GetDefaultCombatModeDecision(orderedPlayerContents).Mode;
|
||||
|
||||
/// <summary>
|
||||
/// Complete result of retail <c>GetDefaultCombatMode</c>, including the
|
||||
/// incompatible Held object used by its exact failure notice.
|
||||
/// </summary>
|
||||
public static DefaultCombatModeDecision GetDefaultCombatModeDecision(
|
||||
IReadOnlyList<ClientObject> 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) =>
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(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<InvalidOperationException>(
|
||||
() => 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<ObjectDisposedException>(() => 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<string> 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<string> calls)
|
||||
: ILiveCombatModeAuthority
|
||||
{
|
||||
public bool IsInWorldValue { get; set; } = true;
|
||||
public bool IsInWorld => IsInWorldValue;
|
||||
public bool ThrowOnSend { get; set; }
|
||||
public List<CombatMode> 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<ClientObject> Items { get; } = [];
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment()
|
||||
{
|
||||
QueryCount++;
|
||||
return Items;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIntent(List<string> calls)
|
||||
: IExplicitCombatModeIntentSink
|
||||
{
|
||||
public void NotifyExplicitCombatModeRequest() => calls.Add("intent");
|
||||
}
|
||||
|
||||
private sealed class FakeCommand : ILiveCombatModeCommand
|
||||
{
|
||||
public int ToggleCount { get; private set; }
|
||||
public void Toggle() => ToggleCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
var toasts = new List<string>();
|
||||
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<string>();
|
||||
|
||||
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<InvalidOperationException>(
|
||||
() => 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<ObjectDisposedException>(() => 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<WorldEntity> Entities { get; } = [];
|
||||
public List<ShadowEntry> Shadows { get; } = [];
|
||||
|
||||
public Vector3 ObserverPosition => ObserverPositionValue;
|
||||
public int LiveCenterX => LiveCenterXValue;
|
||||
public int LiveCenterY => LiveCenterYValue;
|
||||
public int TotalShadowObjects => TotalShadowObjectsValue;
|
||||
public IReadOnlyList<WorldEntity> WorldEntities => Entities;
|
||||
public IEnumerable<ShadowEntry> 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
328
tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs
Normal file
328
tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs
Normal file
|
|
@ -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<string>();
|
||||
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<InvalidOperationException>(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<string>();
|
||||
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<string>();
|
||||
var actions = new FakeActionSurface(calls)
|
||||
{
|
||||
ThrowAfterAdd = true,
|
||||
RemoveFailures = int.MaxValue,
|
||||
};
|
||||
var router = Create(
|
||||
actions,
|
||||
new FakeCombatSurface(calls),
|
||||
new FakeTargets());
|
||||
|
||||
Assert.Throws<AggregateException>(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<string>();
|
||||
var actions = new FakeActionSurface(calls);
|
||||
var combat = new FakeCombatSurface(calls)
|
||||
{
|
||||
RemoveFailures = int.MaxValue,
|
||||
};
|
||||
var router = Create(actions, combat, new FakeTargets());
|
||||
router.Attach();
|
||||
|
||||
Assert.Throws<AggregateException>(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<InputAction, ActivationType> copiedAction =
|
||||
harness.Actions.Callback!;
|
||||
Action<CombatMode> 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<ObjectDisposedException>(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<string>();
|
||||
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<string> calls)
|
||||
: IGameplayInputActionSurface
|
||||
{
|
||||
public Action<InputAction, ActivationType>? Callback { get; private set; }
|
||||
public bool ThrowAfterAdd { get; set; }
|
||||
public bool RaiseDuringAdd { get; set; }
|
||||
public int RemoveFailures { get; set; }
|
||||
public List<InputScope?> Scopes { get; } = [];
|
||||
|
||||
public void AddFired(Action<InputAction, ActivationType> 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<InputAction, ActivationType> 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<string> calls)
|
||||
: ICombatModeEventSurface
|
||||
{
|
||||
public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat;
|
||||
public CombatMode CurrentMode => CurrentModeValue;
|
||||
public Action<CombatMode>? Callback { get; private set; }
|
||||
public bool ThrowAfterAdd { get; set; }
|
||||
public int RemoveFailures { get; set; }
|
||||
|
||||
public void AddChanged(Action<CombatMode> callback)
|
||||
{
|
||||
calls.Add("add-combat");
|
||||
Callback = callback;
|
||||
if (ThrowAfterAdd)
|
||||
throw new InvalidOperationException("combat add");
|
||||
}
|
||||
|
||||
public void RemoveChanged(Action<CombatMode> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string> 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<string> calls)
|
||||
: IRetainedGameplayWindowCommands
|
||||
{
|
||||
public void ToggleInventory() => calls.Add("inventory");
|
||||
}
|
||||
|
||||
private sealed class FakeDevTools(List<string> 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<string> 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<string> 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<string> calls)
|
||||
: IItemTargetModeCommands
|
||||
{
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsAnyTargetModeActive => IsActive;
|
||||
public void CancelTargetMode() => calls.Add("cancel-target");
|
||||
}
|
||||
|
||||
private sealed class FakeCamera(List<string> calls)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
public bool IsFly { get; set; }
|
||||
public bool IsFlyMode => IsFly;
|
||||
public void ExitFlyMode() => calls.Add("exit-fly");
|
||||
}
|
||||
|
||||
private sealed class FakeCombat(List<string> calls) : ILiveCombatModeCommand
|
||||
{
|
||||
public void Toggle() => calls.Add("combat");
|
||||
}
|
||||
|
||||
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
|
||||
{
|
||||
public void Close() => calls.Add("close");
|
||||
}
|
||||
}
|
||||
158
tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs
Normal file
158
tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(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<AggregateException>(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<object, int, int> 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<AggregateException>(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<ObjectDisposedException>(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<object, int, int>? 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<object, int, int> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
Callback = callback;
|
||||
if (RaiseDuringAdd)
|
||||
callback(Item(1), 1, 1);
|
||||
if (ThrowAfterAdd)
|
||||
throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
public void RemoveReleasedOutside(Action<object, int, int> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue