refactor(net): own live session composition
Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
18d4b999de
commit
557eb7ef6b
22 changed files with 1430 additions and 236 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -127,11 +127,17 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||||
update-frame orchestration, and render-frame orchestration all have typed
|
update-frame orchestration, and render-frame orchestration all have typed
|
||||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||||
`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines
|
Slice 8 checkpoints A–C are also complete: dead host residue is removed,
|
||||||
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
|
native-window callback intake has reversible lifetime ownership, and
|
||||||
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
|
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||||
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
|
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
|
||||||
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test
|
||||||
|
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
|
||||||
|
synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment,
|
||||||
|
input/resize, action routing, 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,
|
||||||
#116, remaining
|
#116, remaining
|
||||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||||
lifestone/particle alpha visual gate.
|
lifestone/particle alpha visual gate.
|
||||||
|
|
|
||||||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -125,11 +125,17 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||||
update-frame orchestration, and render-frame orchestration all have typed
|
update-frame orchestration, and render-frame orchestration all have typed
|
||||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||||
`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines
|
Slice 8 checkpoints A–C are also complete: dead host residue is removed,
|
||||||
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
|
native-window callback intake has reversible lifetime ownership, and
|
||||||
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
|
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||||
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
|
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
|
||||||
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test
|
||||||
|
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
|
||||||
|
synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment,
|
||||||
|
input/resize, action routing, 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,
|
||||||
#116, remaining
|
#116, remaining
|
||||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||||
lifestone/particle alpha visual gate.
|
lifestone/particle alpha visual gate.
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,11 @@ What does NOT go here:
|
||||||
## Current queue — 2026-07-22
|
## Current queue — 2026-07-22
|
||||||
|
|
||||||
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
||||||
complete; `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed
|
complete; Slice 8 checkpoints A–C now own native callbacks and live-session
|
||||||
orchestration handoffs and the class is 4,666 lines / 196 fields / 70
|
composition outside the shell. `GameWindow.OnUpdate` and
|
||||||
methods. Final Slice 8 composition/shutdown cleanup is active in
|
`GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589
|
||||||
|
lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown
|
||||||
|
cleanup is active in
|
||||||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||||
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
|
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
|
||||||
|
|
@ -73,6 +75,39 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #233 — Live skill-credit resolver used an ACE shortcut, not retail's formula
|
||||||
|
|
||||||
|
**Status:** DONE (2026-07-22, GameWindow Slice 8 Checkpoint C)
|
||||||
|
**Severity:** MEDIUM
|
||||||
|
**Filed:** 2026-07-22
|
||||||
|
**Component:** live character state / DAT skill formulas
|
||||||
|
|
||||||
|
**Description:** The live skill-update route calculated a skill's primary-
|
||||||
|
attribute contribution with an ACE-oriented shortcut. It rejected every
|
||||||
|
formula whose first multiplier was zero, truncated division, and added `W`
|
||||||
|
after division. Custom DAT formulas—and any stock boundary landing on a half—
|
||||||
|
could therefore display the wrong skill value.
|
||||||
|
|
||||||
|
**Root cause / status:** The binding cited ACE without first checking named
|
||||||
|
retail. `SkillFormula::Calculate @ 0x00591960` treats all four arithmetic words
|
||||||
|
as unsigned, fails only for `Z == 0`, forms the wrapping numerator
|
||||||
|
`X*a + Y*b + W`, and rounds `numerator/Z` half-up. Checkpoint C moved that
|
||||||
|
algorithm into `RetailSkillFormula`, added a named `LiveSkillCreditResolver`,
|
||||||
|
and pinned zero-X, W placement, half rounding, unsigned reinterpretation, and
|
||||||
|
32-bit wrap with conformance tests.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/Net/RetailSkillFormula.cs`;
|
||||||
|
`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`.
|
||||||
|
|
||||||
|
**Research:**
|
||||||
|
[`docs/research/skill_formula_calculate_pseudocode.md`](research/skill_formula_calculate_pseudocode.md).
|
||||||
|
|
||||||
|
**Acceptance:** Live skill updates resolve the DAT entry and reproduce the
|
||||||
|
named retail function for all unsigned field values; missing skills or
|
||||||
|
attributes remain safely projected as zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots
|
## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,9 @@ src/AcDream.App/
|
||||||
│ └── Vfx/ # (already exists)
|
│ └── Vfx/ # (already exists)
|
||||||
├── Net/
|
├── Net/
|
||||||
│ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle
|
│ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle
|
||||||
│ ├── LiveSessionLifecycleHost.cs # narrow App composition + exact borrowed-session attachment
|
│ ├── LiveSessionHost.cs # App reset/selection/entry/route-factory composition over the canonical controller
|
||||||
|
│ ├── LiveSessionLifecycleHost.cs # exact borrowed-session attachment guard
|
||||||
|
│ ├── RetailSkillFormula.cs # named-retail unsigned skill-credit calculation + DAT lookup
|
||||||
│ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers
|
│ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers
|
||||||
├── Physics/
|
├── Physics/
|
||||||
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
||||||
|
|
@ -436,7 +438,7 @@ useful ordering seam, but its ownership status is **partial**.
|
||||||
| Area | Status | Current truth |
|
| Area | Status | Current truth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
|
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
|
||||||
| Network session | **Complete** | `LiveSessionController` owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; `LiveSessionLifecycleHost` is the narrow App composition boundary; `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes. `GameWindow` retains one controller field and focused domain sink factories only (`d9ccf8a6`, `6a5d9e2e`). |
|
| Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. |
|
||||||
| Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). |
|
| Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). |
|
||||||
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |
|
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |
|
||||||
| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. |
|
| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# acdream — strategic roadmap
|
# 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 are complete and final Slice 8 composition/shutdown cleanup is 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–C 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.
|
**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,12 @@ second synchronized soak pass with graceful exits, and the six stable PNG
|
||||||
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
|
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
|
||||||
process-residency gate's diagnostic blind spot; no tolerance was loosened.
|
process-residency gate's diagnostic blind spot; no tolerance was loosened.
|
||||||
|
|
||||||
Final Slice 8 composition/shutdown cleanup is now active.
|
Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–C now own
|
||||||
|
the frozen shell boundary, all nine native callback edges, and live-session
|
||||||
|
reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196
|
||||||
|
fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5
|
||||||
|
skips. Environment, input, settings, startup/resource transfer, and shutdown
|
||||||
|
checkpoints D–L remain.
|
||||||
|
|
||||||
This is a behavior-preserving structural program. Severe regressions still get
|
This is a behavior-preserving structural program. Severe regressions still get
|
||||||
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,10 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is
|
||||||
campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the
|
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
|
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
|
||||||
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
||||||
preserve Slice 6 presentation. Final Slice 8 composition/shutdown cleanup is
|
preserve Slice 6 presentation. Final Slice 8 checkpoints A–C are complete:
|
||||||
active. Issue #232 tracks process-residency variance in the soak without
|
native callbacks and live-session composition now have explicit retryable
|
||||||
|
owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints
|
||||||
|
D–L remain active. Issue #232 tracks process-residency variance in the soak without
|
||||||
loosening its leak threshold.
|
loosening its leak threshold.
|
||||||
|
|
||||||
Carried:
|
Carried:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ audit.
|
||||||
test-facade residue.
|
test-facade residue.
|
||||||
- [x] B — make native-window callback intake an explicit reversible owner and
|
- [x] B — make native-window callback intake an explicit reversible owner and
|
||||||
define host-quiescence failure semantics.
|
define host-quiescence failure semantics.
|
||||||
- [ ] C — extract live-session host/reset/binding callbacks and verify the
|
- [x] C — extract live-session host/reset/binding callbacks and verify the
|
||||||
embedded skill formula against named retail.
|
embedded skill formula against named retail.
|
||||||
- [ ] D — extract retail world-environment/day/weather behavior.
|
- [ ] D — extract retail world-environment/day/weather behavior.
|
||||||
- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer
|
- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer
|
||||||
|
|
@ -450,6 +450,19 @@ frozen callback behavior.
|
||||||
- Keep `LiveSessionController.Start` last; both disabled-live and
|
- Keep `LiveSessionController.Start` last; both disabled-live and
|
||||||
missing-credential starts must execute the reset path.
|
missing-credential starts must execute the reset path.
|
||||||
|
|
||||||
|
Result: `LiveSessionHost` now owns reset-plan construction, exact selection and
|
||||||
|
entered-world ordering, and create→attach route factories while resolving all
|
||||||
|
session/command/in-world state through the sole `LiveSessionController`.
|
||||||
|
Partially attached routes and every individual subscription edge retain failed
|
||||||
|
cleanup work; successful edges are never replayed, and reset/new generations
|
||||||
|
remain blocked until teardown converges. Named-retail research corrected the
|
||||||
|
former ACE-only skill shortcut to exact unsigned `SkillFormula::Calculate @
|
||||||
|
0x00591960` semantics. Three corrected-diff review loops are clean; focused
|
||||||
|
session/formula/ledger tests, the App suite (3,048 pass / 3 intentional skips),
|
||||||
|
Core.Net (548 pass), the Release build, and the full suite (7,408 pass / 5
|
||||||
|
intentional skips) pass. No connected gate was required for this ownership
|
||||||
|
checkpoint; the final connected lifecycle gate remains Checkpoint L.
|
||||||
|
|
||||||
### D — world environment
|
### D — world environment
|
||||||
|
|
||||||
- Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`,
|
- Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`,
|
||||||
|
|
|
||||||
65
docs/research/skill_formula_calculate_pseudocode.md
Normal file
65
docs/research/skill_formula_calculate_pseudocode.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Retail skill-formula calculation
|
||||||
|
|
||||||
|
## Oracle
|
||||||
|
|
||||||
|
- Named retail: `SkillFormula::Calculate @ 0x00591960` in
|
||||||
|
`named-retail/acclient_2013_pseudo_c.txt`.
|
||||||
|
- Retail layout: `SkillFormula` in `named-retail/acclient.h:40199` stores six
|
||||||
|
unsigned 32-bit fields in wire order: `W`, `X`, `Y`, `Z`, `Attr1`, `Attr2`.
|
||||||
|
|
||||||
|
The signed-looking branches in the recovered x87 code are not signed formula
|
||||||
|
semantics. They are the compiler's unsigned-32-bit-to-floating-point fix-up:
|
||||||
|
when the high bit is set, it adds `2^32` after the initial signed conversion.
|
||||||
|
|
||||||
|
## Pseudocode
|
||||||
|
|
||||||
|
```text
|
||||||
|
bool SkillFormula.Calculate(uint attribute1, uint attribute2, out uint result)
|
||||||
|
{
|
||||||
|
uint divisor = Z;
|
||||||
|
if (divisor == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Every operation on this line is uint32 and wraps modulo 2^32.
|
||||||
|
uint numerator = X * attribute1 + Y * attribute2 + W;
|
||||||
|
|
||||||
|
// Formula values are non-negative. Retail rounds to nearest integer,
|
||||||
|
// with exact halves rounding upward.
|
||||||
|
result = uint(floor(double(numerator) / double(divisor) + 0.5));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consequences pinned by the port:
|
||||||
|
|
||||||
|
- `Z == 0` is the only failure gate. `X == 0` remains a valid formula.
|
||||||
|
- `W` participates in the numerator before division; it is not added after
|
||||||
|
division.
|
||||||
|
- multiplication and addition wrap as unsigned 32-bit arithmetic before the
|
||||||
|
floating-point division.
|
||||||
|
- the division rounds half-up; it does not truncate.
|
||||||
|
|
||||||
|
## Reference cross-check
|
||||||
|
|
||||||
|
ACE's [`SkillFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.DatLoader/Entity/SkillFormula.cs)
|
||||||
|
confirms the unsigned DAT order `W, X, Y, Z, Attr1, Attr2`. ACE's
|
||||||
|
[`AttributeFormula.GetFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Entity/AttributeFormula.cs)
|
||||||
|
is only an interpretation aid: it rejects `X == 0`, assumes unit multipliers,
|
||||||
|
omits `W`, and rounds a summed-attribute quotient. That is sufficient for
|
||||||
|
ordinary stock ACE data but is not the general retail algorithm.
|
||||||
|
|
||||||
|
Chorizite DatReaderWriter's generated
|
||||||
|
[`SkillFormula`](https://github.com/Chorizite/DatReaderWriter/blob/master/DatReaderWriter/Generated/Types/SkillFormula.generated.cs)
|
||||||
|
independently confirms the same six-field order and maps its public names to
|
||||||
|
`W`, `X`, `Y`, and `Z`. It exposes the four arithmetic words as signed `int`,
|
||||||
|
so the retail port must reinterpret their bits as `uint`. Its summary places
|
||||||
|
the additive value after division; the named retail function proves that the
|
||||||
|
actual client adds `W` to the numerator instead.
|
||||||
|
|
||||||
|
## Port decision
|
||||||
|
|
||||||
|
`RetailSkillFormula` is the sole App-layer calculation seam. It reinterprets
|
||||||
|
the DAT words as unsigned, performs the exact unchecked 32-bit numerator, and
|
||||||
|
uses the retail half-up division. `LiveSkillCreditResolver` performs only the
|
||||||
|
skill-table lookup and treats a missing attribute as zero, matching the prior
|
||||||
|
live-property projection behavior.
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
The behavior-preserving App ownership campaign is complete through Slice 7.
|
The behavior-preserving App ownership campaign is complete through Slice 7 and
|
||||||
`GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields /
|
Slice 8 checkpoints A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||||
205 methods to 4,666 lines / 196 fields / 70 methods: 11,057 lines (70.3%) were
|
15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69
|
||||||
removed without changing accepted gameplay or rendering behavior.
|
methods: 11,134 lines (70.8%) were removed without changing accepted gameplay
|
||||||
|
or rendering behavior.
|
||||||
|
|
||||||
| Slice | Ownership moved out | Closeout size |
|
| Slice | Ownership moved out | Closeout size |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
|
|
@ -17,6 +18,15 @@ removed without changing accepted gameplay or rendering behavior.
|
||||||
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
|
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
|
||||||
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
|
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
|
||||||
|
|
||||||
|
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
|
||||||
|
edges one reversible owner plus host quiescence; C added `LiveSessionHost` for
|
||||||
|
reset/selection/entry/route composition while keeping `LiveSessionController`
|
||||||
|
the sole session owner. C also replaced the embedded ACE shortcut with the
|
||||||
|
named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route
|
||||||
|
construction and individual detach edges are retained and retried; reset and a
|
||||||
|
new generation cannot pass an incompletely detached route.
|
||||||
|
|
||||||
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
|
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
|
||||||
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
||||||
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
||||||
|
|
@ -73,6 +83,9 @@ review rule live in `docs/architecture/code-structure.md`.
|
||||||
|
|
||||||
## Accepted verification
|
## Accepted verification
|
||||||
|
|
||||||
|
- Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment
|
||||||
|
skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected-
|
||||||
|
diff reviews are clean.
|
||||||
- Release suite: 7,341 passed / 5 fixture or environment skips.
|
- Release suite: 7,341 passed / 5 fixture or environment skips.
|
||||||
- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh
|
- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh
|
||||||
process uncapped reconnect passed; only 25 expected world-edge empty
|
process uncapped reconnect passed; only 25 expected world-edge empty
|
||||||
|
|
@ -96,7 +109,8 @@ review rule live in `docs/architecture/code-structure.md`.
|
||||||
|
|
||||||
## Next work
|
## Next work
|
||||||
|
|
||||||
Slice 8 groups composition and shutdown wiring and reduces Silk callbacks to
|
Slice 8 checkpoints D–L group the remaining environment, input, settings,
|
||||||
|
resource ownership, startup, and shutdown wiring and reduce Silk callbacks to
|
||||||
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
||||||
It removes leftover feature state only where Slices 1–7 already established a
|
It removes leftover feature state only where Slices 1–7 already established a
|
||||||
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
|
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ internal sealed record LiveSessionCommandBindings(
|
||||||
/// itself is the published bus, so a retained reference becomes inert before
|
/// itself is the published bus, so a retained reference becomes inert before
|
||||||
/// the displaced transport is disposed.
|
/// the displaced transport is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable
|
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private LiveCommandBus? _commands;
|
private LiveCommandBus? _commands;
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings(
|
||||||
/// Owns every inbound subscription for one exact live session. Domain state
|
/// Owns every inbound subscription for one exact live session. Domain state
|
||||||
/// remains in the supplied sinks; this class owns only routing and teardown.
|
/// remains in the supplied sinks; this class owns only routing and teardown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LiveSessionEventRouter : IDisposable
|
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||||
{
|
{
|
||||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||||
private readonly Action<int>? _constructionCheckpoint;
|
private readonly Action<int>? _constructionCheckpoint;
|
||||||
|
private readonly WorldSession _session;
|
||||||
|
private readonly LiveEntitySessionSink _entities;
|
||||||
|
private readonly LiveEnvironmentSessionSink _environment;
|
||||||
|
private readonly LiveInventorySessionBindings _inventory;
|
||||||
|
private readonly LiveCharacterSessionBindings _character;
|
||||||
|
private readonly LiveSocialSessionBindings _social;
|
||||||
private int _constructionStep;
|
private int _constructionStep;
|
||||||
private int _accepting = 1;
|
private int _accepting;
|
||||||
|
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
|
||||||
|
|
||||||
public LiveSessionEventRouter(
|
public LiveSessionEventRouter(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
|
|
@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(session);
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
Validate(entities, environment, inventory, character, social);
|
Validate(entities, environment, inventory, character, social);
|
||||||
|
_session = session;
|
||||||
|
_entities = entities;
|
||||||
|
_environment = environment;
|
||||||
|
_inventory = inventory;
|
||||||
|
_character = character;
|
||||||
|
_social = social;
|
||||||
_constructionCheckpoint = constructionCheckpoint;
|
_constructionCheckpoint = constructionCheckpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Attach()
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live-session event routing can only attach once.");
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _accepting, 1);
|
||||||
|
WorldSession session = _session;
|
||||||
|
LiveEntitySessionSink entities = _entities;
|
||||||
|
LiveEnvironmentSessionSink environment = _environment;
|
||||||
|
LiveInventorySessionBindings inventory = _inventory;
|
||||||
|
LiveCharacterSessionBindings character = _character;
|
||||||
|
LiveSocialSessionBindings social = _social;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
||||||
h => session.VitalCurrentUpdated += h,
|
h => session.VitalCurrentUpdated += h,
|
||||||
h => session.VitalCurrentUpdated -= h,
|
h => session.VitalCurrentUpdated -= h,
|
||||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||||
|
|
||||||
|
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
|
||||||
|
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||||
}
|
}
|
||||||
catch (Exception constructionError)
|
catch
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _accepting, 0);
|
Interlocked.Exchange(ref _accepting, 0);
|
||||||
try
|
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||||
{
|
|
||||||
_subscriptions.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception cleanupError)
|
|
||||||
{
|
|
||||||
throw new AggregateException(
|
|
||||||
"live-session event routing failed and cleanup also failed",
|
|
||||||
constructionError,
|
|
||||||
cleanupError);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _accepting, 0);
|
Interlocked.Exchange(ref _accepting, 0);
|
||||||
|
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||||
_subscriptions.Dispose();
|
_subscriptions.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
||||||
};
|
};
|
||||||
|
|
||||||
attach(handler);
|
attach(handler);
|
||||||
try
|
// Add assumes cleanup ownership before it can call an external detach.
|
||||||
{
|
// If the set is already closing, it retains/retries that exact edge;
|
||||||
_subscriptions.Add(() => detach(handler));
|
// calling detach again here would replay a successful removal.
|
||||||
}
|
_subscriptions.Add(() => detach(handler));
|
||||||
catch
|
|
||||||
{
|
|
||||||
detach(handler);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
ConstructionCheckpoint();
|
ConstructionCheckpoint();
|
||||||
}
|
}
|
||||||
|
|
@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
||||||
|
|
||||||
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||||
{
|
{
|
||||||
private List<IDisposable>? _subscriptions = [];
|
private readonly object _gate = new();
|
||||||
|
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||||
|
private bool _disposeRequested;
|
||||||
|
|
||||||
public void Add(IDisposable subscription)
|
public void Add(IDisposable subscription)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(subscription);
|
ArgumentNullException.ThrowIfNull(subscription);
|
||||||
List<IDisposable>? subscriptions = _subscriptions;
|
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||||
if (subscriptions is null)
|
|
||||||
{
|
|
||||||
subscription.Dispose();
|
|
||||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptions.Add(subscription);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
|
public void Add(Action unsubscribe)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||||
|
AddRetained(new RetryableSubscription(unsubscribe));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRetained(RetryableSubscription retained)
|
||||||
|
{
|
||||||
|
bool disposeNow;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
disposeNow = _disposeRequested;
|
||||||
|
_subscriptions.Add(retained);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disposeNow)
|
||||||
|
return;
|
||||||
|
retained.Dispose();
|
||||||
|
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
|
RetryableSubscription[] subscriptions;
|
||||||
if (subscriptions is null)
|
lock (_gate)
|
||||||
return;
|
{
|
||||||
|
_disposeRequested = true;
|
||||||
|
subscriptions = _subscriptions.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
List<Exception>? errors = null;
|
List<Exception>? errors = null;
|
||||||
for (int index = subscriptions.Count - 1; index >= 0; index--)
|
for (int index = subscriptions.Length - 1; index >= 0; index--)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||||
errors);
|
errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
|
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||||
{
|
{
|
||||||
private Action? _unsubscribe = unsubscribe;
|
private readonly object _gate = new();
|
||||||
|
private Action? _dispose = dispose;
|
||||||
|
private bool _executing;
|
||||||
|
private int _executingThreadId;
|
||||||
|
|
||||||
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Action? operation;
|
||||||
|
int threadId = Environment.CurrentManagedThreadId;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
while (_executing)
|
||||||
|
{
|
||||||
|
if (_executingThreadId == threadId)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live-session subscription cleanup cannot complete reentrantly.");
|
||||||
|
}
|
||||||
|
Monitor.Wait(_gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
operation = _dispose;
|
||||||
|
if (operation is null)
|
||||||
|
return;
|
||||||
|
_executing = true;
|
||||||
|
_executingThreadId = threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operation();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
CompleteAttempt(succeeded: false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompleteAttempt(succeeded: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteAttempt(bool succeeded)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (succeeded)
|
||||||
|
_dispose = null;
|
||||||
|
_executing = false;
|
||||||
|
_executingThreadId = 0;
|
||||||
|
Monitor.PulseAll(_gate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
|
internal interface ILiveSessionEventRouting : IDisposable
|
||||||
|
{
|
||||||
|
void Attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
|
||||||
|
{
|
||||||
|
void Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record LiveSessionRoutingFactories(
|
||||||
|
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
|
||||||
|
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
|
||||||
|
|
||||||
|
internal sealed record LiveSessionSelectionBindings(
|
||||||
|
Action<uint> SetPlayerIdentity,
|
||||||
|
Action<uint> SetVitalsIdentity,
|
||||||
|
Action<uint> SetChatIdentity,
|
||||||
|
Action<uint> MarkPersistent,
|
||||||
|
Action<uint> SetVanishProbeIdentity,
|
||||||
|
Action ClearCombat);
|
||||||
|
|
||||||
|
internal sealed record LiveSessionEnteredWorldBindings(
|
||||||
|
Action<string> SetActiveCharacter,
|
||||||
|
Action RestoreLayout,
|
||||||
|
Action SyncToolbar,
|
||||||
|
Action<string> LoadCharacterSettings,
|
||||||
|
Action ArmPlayerModeAutoEntry);
|
||||||
|
|
||||||
|
internal sealed record LiveSessionHostBindings(
|
||||||
|
LiveSessionRoutingFactories Routing,
|
||||||
|
LiveSessionResetBindings Reset,
|
||||||
|
LiveSessionSelectionBindings Selection,
|
||||||
|
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||||
|
Action<string, int, string> Connecting,
|
||||||
|
Action Connected);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
|
||||||
|
/// It owns callback ordering and per-generation route factories, but never
|
||||||
|
/// mirrors session, generation, identity, routing, or command state.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveSessionHost
|
||||||
|
{
|
||||||
|
private sealed class PendingRouteRollback(
|
||||||
|
ILiveSessionCommandRouting? commands,
|
||||||
|
ILiveSessionEventRouting? events)
|
||||||
|
{
|
||||||
|
private ILiveSessionCommandRouting? _commands = commands;
|
||||||
|
private ILiveSessionEventRouting? _events = events;
|
||||||
|
|
||||||
|
public bool IsComplete => _commands is null && _events is null;
|
||||||
|
|
||||||
|
public void Drain()
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
TryDispose(ref _commands, ref failures);
|
||||||
|
TryDispose(ref _events, ref failures);
|
||||||
|
if (failures is not null)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Live-session route rollback did not converge.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDispose<TOwner>(
|
||||||
|
ref TOwner? owner,
|
||||||
|
ref List<Exception>? failures)
|
||||||
|
where TOwner : class, IDisposable
|
||||||
|
{
|
||||||
|
if (owner is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
owner.Dispose();
|
||||||
|
owner = null;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly LiveSessionController _controller;
|
||||||
|
private readonly LiveSessionRoutingFactories _routing;
|
||||||
|
private readonly LiveSessionSelectionBindings _selection;
|
||||||
|
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||||
|
private readonly LiveSessionResetPlan _resetPlan;
|
||||||
|
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||||
|
private PendingRouteRollback? _pendingRouteRollback;
|
||||||
|
|
||||||
|
public LiveSessionHost(
|
||||||
|
LiveSessionController controller,
|
||||||
|
LiveSessionHostBindings bindings)
|
||||||
|
{
|
||||||
|
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
|
||||||
|
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||||
|
_enteredWorld = bindings.EnteredWorld
|
||||||
|
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||||
|
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||||
|
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||||
|
Validate(_selection, _enteredWorld);
|
||||||
|
|
||||||
|
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
|
||||||
|
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
|
||||||
|
Bind: BindSession,
|
||||||
|
Reset: ResetSessionState,
|
||||||
|
Connecting: bindings.Connecting,
|
||||||
|
Connected: bindings.Connected,
|
||||||
|
Selected: ApplySelection,
|
||||||
|
Entered: ApplyEnteredWorld));
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldSession? CurrentSession => _controller.CurrentSession;
|
||||||
|
public ICommandBus Commands => _controller.Commands;
|
||||||
|
public bool IsInWorld => _controller.IsInWorld;
|
||||||
|
|
||||||
|
public LiveSessionStartResult Start(RuntimeOptions options) =>
|
||||||
|
_controller.Start(options, _lifecycle);
|
||||||
|
|
||||||
|
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
|
||||||
|
_controller.Reconnect(options, _lifecycle);
|
||||||
|
|
||||||
|
private LiveSessionBinding BindSession(WorldSession session)
|
||||||
|
{
|
||||||
|
DrainPendingRouteRollback();
|
||||||
|
|
||||||
|
ILiveSessionEventRouting? events = null;
|
||||||
|
ILiveSessionCommandRouting? commands = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = _routing.CreateEvents(session)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The live-session event factory returned null.");
|
||||||
|
events.Attach();
|
||||||
|
commands = _routing.CreateCommands(session)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The live-session command factory returned null.");
|
||||||
|
return new LiveSessionBinding(
|
||||||
|
session,
|
||||||
|
commands,
|
||||||
|
activateCommands: commands.Activate,
|
||||||
|
deactivateCommands: commands.Dispose,
|
||||||
|
detachEvents: events.Dispose);
|
||||||
|
}
|
||||||
|
catch (Exception creationError)
|
||||||
|
{
|
||||||
|
RethrowWithRetryableRollback(creationError, commands, events);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSessionState()
|
||||||
|
{
|
||||||
|
// An incompletely detached route can still deliver callbacks into the
|
||||||
|
// state below. Treat physical route convergence as the same hard
|
||||||
|
// barrier used by normal LiveSessionBinding teardown.
|
||||||
|
DrainPendingRouteRollback();
|
||||||
|
_resetPlan.Execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySelection(LiveSessionCharacterSelection selection)
|
||||||
|
{
|
||||||
|
uint id = selection.CharacterId;
|
||||||
|
_selection.SetPlayerIdentity(id);
|
||||||
|
_selection.SetVitalsIdentity(id);
|
||||||
|
_selection.SetChatIdentity(id);
|
||||||
|
_selection.MarkPersistent(id);
|
||||||
|
_selection.SetVanishProbeIdentity(id);
|
||||||
|
_selection.ClearCombat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
||||||
|
{
|
||||||
|
string name = selection.CharacterName;
|
||||||
|
_enteredWorld.SetActiveCharacter(name);
|
||||||
|
_enteredWorld.RestoreLayout();
|
||||||
|
_enteredWorld.SyncToolbar();
|
||||||
|
_enteredWorld.LoadCharacterSettings(name);
|
||||||
|
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RethrowWithRetryableRollback(
|
||||||
|
Exception creationError,
|
||||||
|
ILiveSessionCommandRouting? commands,
|
||||||
|
ILiveSessionEventRouting? events)
|
||||||
|
{
|
||||||
|
_pendingRouteRollback = new PendingRouteRollback(commands, events);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DrainPendingRouteRollback();
|
||||||
|
}
|
||||||
|
catch (AggregateException cleanupError)
|
||||||
|
{
|
||||||
|
var failures = new List<Exception> { creationError };
|
||||||
|
failures.AddRange(cleanupError.InnerExceptions);
|
||||||
|
throw new AggregateException(
|
||||||
|
"Live-session route construction and rollback both failed.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExceptionDispatchInfo.Capture(creationError).Throw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrainPendingRouteRollback()
|
||||||
|
{
|
||||||
|
if (_pendingRouteRollback is not { } rollback)
|
||||||
|
return;
|
||||||
|
rollback.Drain();
|
||||||
|
if (rollback.IsComplete)
|
||||||
|
_pendingRouteRollback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(
|
||||||
|
LiveSessionSelectionBindings selection,
|
||||||
|
LiveSessionEnteredWorldBindings entered)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
|
||||||
|
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
|
||||||
|
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
|
||||||
|
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
|
||||||
|
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
|
||||||
|
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact unsigned formula used by retail <c>SkillFormula::Calculate @
|
||||||
|
/// 0x00591960</c>. DAT reader fields are signed storage views, so their bits
|
||||||
|
/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RetailSkillFormula
|
||||||
|
{
|
||||||
|
public static bool TryCalculate(
|
||||||
|
SkillFormula formula,
|
||||||
|
uint attribute1,
|
||||||
|
uint attribute2,
|
||||||
|
out uint result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(formula);
|
||||||
|
|
||||||
|
uint divisor = unchecked((uint)formula.Divisor);
|
||||||
|
if (divisor == 0u)
|
||||||
|
{
|
||||||
|
result = 0u;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint x = unchecked((uint)formula.Attribute1Multiplier);
|
||||||
|
uint y = unchecked((uint)formula.Attribute2Multiplier);
|
||||||
|
uint w = unchecked((uint)formula.AdditiveBonus);
|
||||||
|
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
|
||||||
|
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named live-session resolver for the attribute contribution to one skill.
|
||||||
|
/// Table lookup and missing-property policy stay separate from retail math.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
|
||||||
|
{
|
||||||
|
public uint Resolve(
|
||||||
|
uint skillId,
|
||||||
|
IReadOnlyDictionary<uint, uint> attributeCurrents)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attributeCurrents);
|
||||||
|
|
||||||
|
if (skillTable?.Skills is null
|
||||||
|
|| !skillTable.Skills.TryGetValue(
|
||||||
|
(DatReaderWriter.Enums.SkillId)skillId,
|
||||||
|
out var skillBase))
|
||||||
|
{
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
SkillFormula formula = skillBase.Formula;
|
||||||
|
attributeCurrents.TryGetValue(
|
||||||
|
(uint)formula.Attribute1,
|
||||||
|
out uint attribute1);
|
||||||
|
attributeCurrents.TryGetValue(
|
||||||
|
(uint)formula.Attribute2,
|
||||||
|
out uint attribute2);
|
||||||
|
return RetailSkillFormula.TryCalculate(
|
||||||
|
formula,
|
||||||
|
attribute1,
|
||||||
|
attribute2,
|
||||||
|
out uint result)
|
||||||
|
? result
|
||||||
|
: 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -380,10 +380,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// DevToolsEnabled reads through typed RuntimeOptions.
|
// DevToolsEnabled reads through typed RuntimeOptions.
|
||||||
private bool DevToolsEnabled => _options.DevTools;
|
private bool DevToolsEnabled => _options.DevTools;
|
||||||
|
|
||||||
// Slice 3: the reset transaction remains cached by the host, while the
|
|
||||||
// lifecycle controller owns each exact event/command/session generation.
|
|
||||||
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
|
|
||||||
|
|
||||||
// Phase G.1-G.2 world lighting/time state.
|
// Phase G.1-G.2 world lighting/time state.
|
||||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||||
new AcDream.Core.World.WorldTimeService(
|
new AcDream.Core.World.WorldTimeService(
|
||||||
|
|
@ -485,7 +481,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
||||||
// by ApplyLiveSessionEnteredWorld; ticked from
|
// by LiveSessionHost's entered-world transition; ticked from
|
||||||
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
||||||
// other path that pre-empts the chase camera). Skipped entirely
|
// other path that pre-empts the chase camera). Skipped entirely
|
||||||
// offline (orbit camera stays the foreground). The class internally
|
// offline (orbit camera stays the foreground). The class internally
|
||||||
|
|
@ -529,8 +525,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// Slice 3: the controller is the sole App owner. Outbound feature
|
// Slice 3: the controller is the sole App owner. Outbound feature
|
||||||
// callbacks resolve this borrowed handle at call time and never cache it.
|
// callbacks resolve this borrowed handle at call time and never cache it.
|
||||||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||||
|
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
|
||||||
private AcDream.Core.Net.WorldSession? LiveSession =>
|
private AcDream.Core.Net.WorldSession? LiveSession =>
|
||||||
_liveSessionController?.CurrentSession;
|
_liveSessionHost?.CurrentSession;
|
||||||
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
||||||
private int _liveCenterX => _liveWorldOrigin.CenterX;
|
private int _liveCenterX => _liveWorldOrigin.CenterX;
|
||||||
private int _liveCenterY => _liveWorldOrigin.CenterY;
|
private int _liveCenterY => _liveWorldOrigin.CenterY;
|
||||||
|
|
@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// _activeToonKey is updated by
|
// _activeToonKey is updated by
|
||||||
// ApplyLiveSessionEnteredWorld
|
// LiveSessionHost entered-world transition
|
||||||
// so saving character settings always
|
// so saving character settings always
|
||||||
// writes under the chosen character's
|
// writes under the chosen character's
|
||||||
// name (or "default" pre-login).
|
// name (or "default" pre-login).
|
||||||
|
|
@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable
|
||||||
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
||||||
.DragItemOnPlayerOpensSecureTrade) != 0,
|
.DragItemOnPlayerOpensSecureTrade) != 0,
|
||||||
toast: text => _debugVm?.AddToast(text),
|
toast: text => _debugVm?.AddToast(text),
|
||||||
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
|
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
|
||||||
playerOnGround: GetDebugPlayerOnGround,
|
playerOnGround: GetDebugPlayerOnGround,
|
||||||
inNonCombatMode: () => Combat.CurrentMode
|
inNonCombatMode: () => Combat.CurrentMode
|
||||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||||
|
|
@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable
|
||||||
playerGuid: () => _playerServerGuid,
|
playerGuid: () => _playerServerGuid,
|
||||||
activeToonName: () => _activeToonKey,
|
activeToonName: () => _activeToonKey,
|
||||||
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
||||||
canSendRaise: () => _liveSessionController?.IsInWorld == true,
|
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
|
||||||
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
|
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
|
||||||
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
|
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
|
||||||
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
|
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
|
||||||
|
|
@ -1555,7 +1552,7 @@ public sealed class GameWindow : IDisposable
|
||||||
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
|
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
|
||||||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||||
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
||||||
canSend: () => _liveSessionController?.IsInWorld == true);
|
canSend: () => _liveSessionHost?.IsInWorld == true);
|
||||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||||
|
|
||||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||||
|
|
@ -1653,7 +1650,7 @@ public sealed class GameWindow : IDisposable
|
||||||
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
||||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||||
retailChatVm,
|
retailChatVm,
|
||||||
() => _liveSessionController?.Commands
|
() => _liveSessionHost?.Commands
|
||||||
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
|
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||||
radarSnapshotProvider.BuildSnapshot,
|
radarSnapshotProvider.BuildSnapshot,
|
||||||
|
|
@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable
|
||||||
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
||||||
_playerModeAutoEntry),
|
_playerModeAutoEntry),
|
||||||
cameraFrame);
|
cameraFrame);
|
||||||
|
_liveSessionHost = CreateLiveSessionHost();
|
||||||
AcDream.App.Net.LiveSessionStartResult liveStart =
|
AcDream.App.Net.LiveSessionStartResult liveStart =
|
||||||
_liveSessionController.Start(
|
_liveSessionHost.Start(_options);
|
||||||
_options,
|
|
||||||
CreateLiveSessionLifecycleHost());
|
|
||||||
switch (liveStart.Status)
|
switch (liveStart.Status)
|
||||||
{
|
{
|
||||||
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
|
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
|
||||||
|
|
@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
|
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
|
||||||
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
|
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
|
||||||
Bind: CreateLiveSessionBinding,
|
Routing: new(
|
||||||
Reset: () =>
|
CreateLiveSessionEventRouter,
|
||||||
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
|
session => new AcDream.App.Net.LiveSessionCommandRouter(
|
||||||
|
CreateLiveSessionCommandBindings(session))),
|
||||||
|
Reset: CreateLiveSessionResetBindings(),
|
||||||
|
Selection: new(
|
||||||
|
SetPlayerIdentity: id => _playerServerGuid = id,
|
||||||
|
SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id),
|
||||||
|
SetChatIdentity: Chat.SetLocalPlayerGuid,
|
||||||
|
MarkPersistent: _worldState.MarkPersistent,
|
||||||
|
SetVanishProbeIdentity: id =>
|
||||||
|
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
|
||||||
|
ClearCombat: Combat.Clear),
|
||||||
|
EnteredWorld: new(
|
||||||
|
SetActiveCharacter: name => _activeToonKey = name,
|
||||||
|
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
|
||||||
|
SyncToolbar: SyncToolbarWindowButtons,
|
||||||
|
LoadCharacterSettings: LoadLiveSessionCharacterSettings,
|
||||||
|
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
|
||||||
Connecting: (host, port, user) =>
|
Connecting: (host, port, user) =>
|
||||||
Chat.OnSystemMessage(
|
Chat.OnSystemMessage(
|
||||||
$"connecting to {host}:{port} as {user}",
|
$"connecting to {host}:{port} as {user}",
|
||||||
|
|
@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable
|
||||||
Connected: () =>
|
Connected: () =>
|
||||||
Chat.OnSystemMessage(
|
Chat.OnSystemMessage(
|
||||||
"connected — character list received",
|
"connected — character list received",
|
||||||
chatType: 1),
|
chatType: 1)));
|
||||||
Selected: ApplyLiveSessionSelection,
|
|
||||||
Entered: ApplyLiveSessionEnteredWorld));
|
|
||||||
|
|
||||||
private void ApplyLiveSessionSelection(
|
private void LoadLiveSessionCharacterSettings(string characterName)
|
||||||
AcDream.App.Net.LiveSessionCharacterSelection selection)
|
|
||||||
{
|
{
|
||||||
_playerServerGuid = selection.CharacterId;
|
|
||||||
_vitalsVm?.SetLocalPlayerGuid(selection.CharacterId);
|
|
||||||
Chat.SetLocalPlayerGuid(selection.CharacterId);
|
|
||||||
_worldState.MarkPersistent(selection.CharacterId);
|
|
||||||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId;
|
|
||||||
Combat.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyLiveSessionEnteredWorld(
|
|
||||||
AcDream.App.Net.LiveSessionCharacterSelection selection)
|
|
||||||
{
|
|
||||||
_activeToonKey = selection.CharacterName;
|
|
||||||
_retailUiRuntime?.RestoreLayout();
|
|
||||||
SyncToolbarWindowButtons();
|
|
||||||
if (_settingsStore is not null && _settingsVm is not null)
|
if (_settingsStore is not null && _settingsVm is not null)
|
||||||
{
|
{
|
||||||
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
var toonBag = _settingsStore.LoadCharacter(characterName);
|
||||||
_settingsVm.LoadCharacterContext(toonBag);
|
_settingsVm.LoadCharacterContext(toonBag);
|
||||||
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
|
Console.WriteLine(
|
||||||
|
$"settings: loaded character[{characterName}] preferences");
|
||||||
}
|
}
|
||||||
_playerModeAutoEntry?.Arm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
|
private AcDream.App.Net.LiveSessionResetBindings
|
||||||
AcDream.App.Net.LiveSessionResetManifest.Create(new()
|
CreateLiveSessionResetBindings() => new()
|
||||||
{
|
{
|
||||||
MouseCapture = ResetSessionMouseCapture,
|
MouseCapture = ResetSessionMouseCapture,
|
||||||
PlayerPresentation = ResetSessionPlayerPresentation,
|
PlayerPresentation = ResetSessionPlayerPresentation,
|
||||||
|
|
@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable
|
||||||
AnimationHookFrames = () => _animationHookFrames?.Clear(),
|
AnimationHookFrames = () => _animationHookFrames?.Clear(),
|
||||||
LivePresentation = () => _liveEntityPresentation?.Clear(),
|
LivePresentation = () => _liveEntityPresentation?.Clear(),
|
||||||
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
|
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
|
||||||
});
|
};
|
||||||
|
|
||||||
private void ResetSessionMouseCapture()
|
private void ResetSessionMouseCapture()
|
||||||
=> _gameplayInputFrame?.ResetSession();
|
=> _gameplayInputFrame?.ResetSession();
|
||||||
|
|
@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
|
||||||
/// Composes the exact inbound and outbound owners for one live session.
|
|
||||||
/// Registration completes before Connect; outbound commands remain inert
|
|
||||||
/// until EnterWorld succeeds.
|
|
||||||
/// </summary>
|
|
||||||
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
|
|
||||||
AcDream.Core.Net.WorldSession session)
|
AcDream.Core.Net.WorldSession session)
|
||||||
{
|
{
|
||||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||||
|
|
@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable
|
||||||
Console.WriteLine);
|
Console.WriteLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
return new AcDream.App.Net.LiveSessionEventRouter(
|
||||||
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
session,
|
||||||
try
|
_liveEntitySessionEvents.CreateSink(),
|
||||||
{
|
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||||
events = new AcDream.App.Net.LiveSessionEventRouter(
|
OnEnvironChanged,
|
||||||
session,
|
ticks =>
|
||||||
_liveEntitySessionEvents.CreateSink(),
|
{
|
||||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
WorldTime.SyncFromServer(ticks);
|
||||||
OnEnvironChanged,
|
RefreshSkyForCurrentDay();
|
||||||
ticks =>
|
}),
|
||||||
{
|
CreateLiveInventorySessionBindings(),
|
||||||
WorldTime.SyncFromServer(ticks);
|
CreateLiveCharacterSessionBindings(skillTable),
|
||||||
RefreshSkyForCurrentDay();
|
CreateLiveSocialSessionBindings());
|
||||||
}),
|
|
||||||
CreateLiveInventorySessionBindings(),
|
|
||||||
CreateLiveCharacterSessionBindings(skillTable),
|
|
||||||
CreateLiveSocialSessionBindings());
|
|
||||||
|
|
||||||
commands = new AcDream.App.Net.LiveSessionCommandRouter(
|
|
||||||
CreateLiveSessionCommandBindings(session));
|
|
||||||
return new AcDream.App.Net.LiveSessionBinding(
|
|
||||||
session,
|
|
||||||
commands,
|
|
||||||
activateCommands: commands.Activate,
|
|
||||||
deactivateCommands: commands.Dispose,
|
|
||||||
detachEvents: events.Dispose);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
commands?.Dispose();
|
|
||||||
events?.Dispose();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.Net.LiveInventorySessionBindings
|
private AcDream.App.Net.LiveInventorySessionBindings
|
||||||
|
|
@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private AcDream.App.Net.LiveCharacterSessionBindings
|
private AcDream.App.Net.LiveCharacterSessionBindings
|
||||||
CreateLiveCharacterSessionBindings(
|
CreateLiveCharacterSessionBindings(
|
||||||
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
|
DatReaderWriter.DBObjs.SkillTable? skillTable)
|
||||||
|
{
|
||||||
|
var skillCreditResolver =
|
||||||
|
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
|
||||||
|
return new(
|
||||||
Combat,
|
Combat,
|
||||||
SpellBook,
|
SpellBook,
|
||||||
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
|
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
||||||
{
|
|
||||||
// ACE AttributeFormula.GetFormula
|
|
||||||
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
|
|
||||||
// Attribute1Multiplier == 0 means no attribute contribution.
|
|
||||||
// Otherwise the retail data formula is
|
|
||||||
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
|
|
||||||
// Guard a zero divisor because malformed/custom DAT input must not
|
|
||||||
// take down the live-session dispatcher.
|
|
||||||
if (skillTable?.Skills is null)
|
|
||||||
return 0u;
|
|
||||||
if (!skillTable.Skills.TryGetValue(
|
|
||||||
(DatReaderWriter.Enums.SkillId)skillId,
|
|
||||||
out var skillBase))
|
|
||||||
return 0u;
|
|
||||||
|
|
||||||
var formula = skillBase.Formula;
|
|
||||||
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
|
|
||||||
return 0u;
|
|
||||||
|
|
||||||
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
|
|
||||||
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
|
|
||||||
long numerator =
|
|
||||||
(long)attribute1 * formula.Attribute1Multiplier +
|
|
||||||
(long)attribute2 * formula.Attribute2Multiplier;
|
|
||||||
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
|
|
||||||
return bonus < 0 ? 0u : (uint)bonus;
|
|
||||||
},
|
|
||||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||||
{
|
{
|
||||||
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
|
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
|
||||||
|
|
@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable
|
||||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||||
options1,
|
options1,
|
||||||
ClientTime: ClientTimerNow);
|
ClientTime: ClientTimerNow);
|
||||||
|
}
|
||||||
|
|
||||||
private AcDream.App.Net.LiveSocialSessionBindings
|
private AcDream.App.Net.LiveSocialSessionBindings
|
||||||
CreateLiveSocialSessionBindings() => new(
|
CreateLiveSocialSessionBindings() => new(
|
||||||
|
|
@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// optional SettingsPanel and its visibility/input operations.
|
// optional SettingsPanel and its visibility/input operations.
|
||||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
|
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
|
||||||
// L.0: settings.json store + active toon key. The store is held as
|
// L.0: settings.json store + active toon key. The store is held as
|
||||||
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
|
// a field so LiveSessionHost can re-load the chosen toon's
|
||||||
// bag once we know its name (post-EnterWorld). Toon key starts as
|
// bag once we know its name (post-EnterWorld). Toon key starts as
|
||||||
// "default" and gets swapped to the actual character name on the
|
// "default" and gets swapped to the actual character name on the
|
||||||
// first EnterWorld.
|
// first EnterWorld.
|
||||||
|
|
@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_persistedGameplay = _settingsStore.LoadGameplay();
|
_persistedGameplay = _settingsStore.LoadGameplay();
|
||||||
_persistedChat = _settingsStore.LoadChat();
|
_persistedChat = _settingsStore.LoadChat();
|
||||||
// _activeToonKey is "default" pre-EnterWorld; the post-login
|
// _activeToonKey is "default" pre-EnterWorld; the post-login
|
||||||
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
|
// LiveSessionHost swaps to the chosen toon's
|
||||||
// name and re-loads via SettingsVM.LoadCharacterContext.
|
// name and re-loads via SettingsVM.LoadCharacterContext.
|
||||||
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
|
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
|
||||||
|
|
||||||
|
|
@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private void ToggleLiveCombatMode()
|
private void ToggleLiveCombatMode()
|
||||||
{
|
{
|
||||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
|
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
|
||||||
|
|
@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private void UseItemByGuid(uint guid)
|
private void UseItemByGuid(uint guid)
|
||||||
{
|
{
|
||||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||||
return;
|
return;
|
||||||
uint sequence = session.NextGameActionSequence();
|
uint sequence = session.NextGameActionSequence();
|
||||||
session.SendGameAction(
|
session.SendGameAction(
|
||||||
|
|
@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ReferenceEquals(_liveSessionController, controller))
|
if (ReferenceEquals(_liveSessionController, controller))
|
||||||
|
{
|
||||||
|
_liveSessionHost = null;
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
// Frame composition borrows equipped, effect, audio, and render
|
// Frame composition borrows equipped, effect, audio, and render
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,48 @@ namespace AcDream.Core.Net;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SubscriptionSet : IDisposable
|
internal sealed class SubscriptionSet : IDisposable
|
||||||
{
|
{
|
||||||
private List<IDisposable>? _subscriptions = [];
|
private readonly object _gate = new();
|
||||||
|
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||||
|
private bool _disposeRequested;
|
||||||
|
|
||||||
public void Add(IDisposable subscription)
|
public void Add(IDisposable subscription)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(subscription);
|
ArgumentNullException.ThrowIfNull(subscription);
|
||||||
List<IDisposable>? subscriptions = _subscriptions;
|
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||||
if (subscriptions is null)
|
|
||||||
{
|
|
||||||
subscription.Dispose();
|
|
||||||
throw new ObjectDisposedException(nameof(SubscriptionSet));
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptions.Add(subscription);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Add(Action unsubscribe)
|
public void Add(Action unsubscribe)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(unsubscribe);
|
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||||
Add(new ActionSubscription(unsubscribe));
|
AddRetained(new RetryableSubscription(unsubscribe));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRetained(RetryableSubscription retained)
|
||||||
|
{
|
||||||
|
bool disposeNow;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
disposeNow = _disposeRequested;
|
||||||
|
_subscriptions.Add(retained);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disposeNow)
|
||||||
|
return;
|
||||||
|
retained.Dispose();
|
||||||
|
throw new ObjectDisposedException(nameof(SubscriptionSet));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
List<IDisposable>? subscriptions =
|
RetryableSubscription[] subscriptions;
|
||||||
Interlocked.Exchange(ref _subscriptions, null);
|
lock (_gate)
|
||||||
if (subscriptions is null)
|
{
|
||||||
return;
|
_disposeRequested = true;
|
||||||
|
subscriptions = _subscriptions.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
List<Exception>? errors = null;
|
List<Exception>? errors = null;
|
||||||
for (int i = subscriptions.Count - 1; i >= 0; i--)
|
for (int i = subscriptions.Length - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable
|
||||||
throw new AggregateException("one or more subscriptions failed to detach", errors);
|
throw new AggregateException("one or more subscriptions failed to detach", errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
|
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||||
{
|
{
|
||||||
private Action? _unsubscribe = unsubscribe;
|
private readonly object _gate = new();
|
||||||
|
private Action? _dispose = dispose;
|
||||||
|
private bool _executing;
|
||||||
|
private int _executingThreadId;
|
||||||
|
|
||||||
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Action? operation;
|
||||||
|
int threadId = Environment.CurrentManagedThreadId;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
while (_executing)
|
||||||
|
{
|
||||||
|
if (_executingThreadId == threadId)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Subscription cleanup cannot complete reentrantly.");
|
||||||
|
}
|
||||||
|
Monitor.Wait(_gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
operation = _dispose;
|
||||||
|
if (operation is null)
|
||||||
|
return;
|
||||||
|
_executing = true;
|
||||||
|
_executingThreadId = threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operation();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
CompleteAttempt(succeeded: false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompleteAttempt(succeeded: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteAttempt(bool succeeded)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (succeeded)
|
||||||
|
_dispose = null;
|
||||||
|
_executing = false;
|
||||||
|
_executingThreadId = 0;
|
||||||
|
Monitor.PulseAll(_gate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
|
public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession()
|
||||||
{
|
{
|
||||||
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
|
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
|
||||||
|
|
||||||
|
|
@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
||||||
fields,
|
fields,
|
||||||
field => field.Name == "_liveSessionController"
|
field => field.Name == "_liveSessionController"
|
||||||
&& field.FieldType == typeof(LiveSessionController));
|
&& field.FieldType == typeof(LiveSessionController));
|
||||||
|
Assert.Contains(
|
||||||
|
fields,
|
||||||
|
field => field.Name == "_liveSessionHost"
|
||||||
|
&& field.FieldType == typeof(LiveSessionHost));
|
||||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
|
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
|
||||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
|
||||||
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan));
|
||||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
|
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
|
||||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
|
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
||||||
[InlineData("ClearInboundEntityState")]
|
[InlineData("ClearInboundEntityState")]
|
||||||
[InlineData("WireLiveSessionEvents")]
|
[InlineData("WireLiveSessionEvents")]
|
||||||
[InlineData("DisposeLiveSessionRouting")]
|
[InlineData("DisposeLiveSessionRouting")]
|
||||||
|
[InlineData("CreateLiveSessionBinding")]
|
||||||
|
[InlineData("ApplyLiveSessionSelection")]
|
||||||
|
[InlineData("ApplyLiveSessionEnteredWorld")]
|
||||||
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
|
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
|
||||||
{
|
{
|
||||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
|
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
public sealed class LiveSessionEventRouterTests
|
public sealed class LiveSessionEventRouterTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreatedRouterPublishesNoHandlersUntilExplicitAttach()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
var router = new LiveSessionEventRouter(
|
||||||
|
session,
|
||||||
|
new LiveEntitySessionSink(
|
||||||
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
||||||
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
|
||||||
|
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
||||||
|
NewInventoryBindings(),
|
||||||
|
NewCharacterBindings(),
|
||||||
|
NewSocialBindings());
|
||||||
|
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
|
||||||
|
router.Attach();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||||
|
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
||||||
|
|
||||||
|
router.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
||||||
{
|
{
|
||||||
|
|
@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests
|
||||||
Counters counters,
|
Counters counters,
|
||||||
Action<int>? constructionCheckpoint = null,
|
Action<int>? constructionCheckpoint = null,
|
||||||
CombatState? combat = null,
|
CombatState? combat = null,
|
||||||
ChatLog? chat = null) => new(
|
ChatLog? chat = null)
|
||||||
session,
|
{
|
||||||
new LiveEntitySessionSink(
|
var router = new LiveSessionEventRouter(
|
||||||
Spawned: _ => { },
|
session,
|
||||||
Deleted: _ => { },
|
new LiveEntitySessionSink(
|
||||||
PickedUp: _ => { },
|
Spawned: _ => { },
|
||||||
MotionUpdated: _ => { },
|
Deleted: _ => { },
|
||||||
PositionUpdated: _ => { },
|
PickedUp: _ => { },
|
||||||
VectorUpdated: _ => { },
|
MotionUpdated: _ => { },
|
||||||
StateUpdated: _ => { },
|
PositionUpdated: _ => { },
|
||||||
ParentUpdated: _ => { },
|
VectorUpdated: _ => { },
|
||||||
TeleportStarted: _ => counters.Teleport++,
|
StateUpdated: _ => { },
|
||||||
AppearanceUpdated: _ => { },
|
ParentUpdated: _ => { },
|
||||||
PlayPhysicsScript: _ => { },
|
TeleportStarted: _ => counters.Teleport++,
|
||||||
PlayPhysicsScriptType: _ => { }),
|
AppearanceUpdated: _ => { },
|
||||||
new LiveEnvironmentSessionSink(
|
PlayPhysicsScript: _ => { },
|
||||||
EnvironChanged: _ => { },
|
PlayPhysicsScriptType: _ => { }),
|
||||||
ServerTimeUpdated: _ =>
|
new LiveEnvironmentSessionSink(
|
||||||
{
|
EnvironChanged: _ => { },
|
||||||
if (counters.ThrowOnServerTime)
|
ServerTimeUpdated: _ =>
|
||||||
throw new InvalidOperationException("injected sink failure");
|
{
|
||||||
counters.ServerTime++;
|
if (counters.ThrowOnServerTime)
|
||||||
}),
|
throw new InvalidOperationException("injected sink failure");
|
||||||
NewInventoryBindings(),
|
counters.ServerTime++;
|
||||||
NewCharacterBindings(combat),
|
}),
|
||||||
NewSocialBindings(chat),
|
NewInventoryBindings(),
|
||||||
constructionCheckpoint);
|
NewCharacterBindings(combat),
|
||||||
|
NewSocialBindings(chat),
|
||||||
|
constructionCheckpoint);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
router.Attach();
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
router.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
||||||
new ClientObjectTable(),
|
new ClientObjectTable(),
|
||||||
|
|
|
||||||
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
using System.Net;
|
||||||
|
using AcDream.App.Net;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
|
public sealed class LiveSessionHostTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
var events = new TestEventRouting(calls);
|
||||||
|
var commands = new TestCommandRouting(calls);
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
calls.Add("events");
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
calls.Add("commands");
|
||||||
|
return commands;
|
||||||
|
});
|
||||||
|
|
||||||
|
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||||
|
"connecting", "connect", "connected",
|
||||||
|
"player:1342177282", "vitals:1342177282",
|
||||||
|
"chat:1342177282", "persistent:1342177282",
|
||||||
|
"vanish:1342177282", "clear-combat", "enter:1",
|
||||||
|
"activate", "active:Ready", "restore-layout",
|
||||||
|
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||||
|
Assert.Same(commands, host.Commands);
|
||||||
|
Assert.True(host.IsInWorld);
|
||||||
|
|
||||||
|
controller.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ => throw new InvalidOperationException("must not bind"),
|
||||||
|
_ => throw new InvalidOperationException("must not bind"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
LiveSessionStartStatus.Disabled,
|
||||||
|
host.Start(LiveOptions(live: false)).Status);
|
||||||
|
Assert.Equal(
|
||||||
|
LiveSessionStartStatus.MissingCredentials,
|
||||||
|
host.Start(LiveOptions(user: null)).Status);
|
||||||
|
|
||||||
|
Assert.Equal(["reset", "reset"], calls);
|
||||||
|
Assert.Null(host.CurrentSession);
|
||||||
|
Assert.False(host.IsInWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
var events = new TestEventRouting(calls);
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ => events,
|
||||||
|
_ => throw new InvalidOperationException("command failure"));
|
||||||
|
|
||||||
|
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||||
|
Assert.IsType<InvalidOperationException>(result.Error);
|
||||||
|
Assert.Equal(1, events.DisposeCount);
|
||||||
|
Assert.Contains("dispose-events", calls);
|
||||||
|
Assert.Null(host.CurrentSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
var firstEvents = new TestEventRouting(calls)
|
||||||
|
{
|
||||||
|
AttachFailuresRemaining = 1,
|
||||||
|
FailuresRemaining = 1,
|
||||||
|
};
|
||||||
|
int eventFactoryCount = 0;
|
||||||
|
int commandFactoryCount = 0;
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ => eventFactoryCount++ == 0
|
||||||
|
? firstEvents
|
||||||
|
: new TestEventRouting(calls),
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
commandFactoryCount++;
|
||||||
|
return new TestCommandRouting(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
|
||||||
|
Assert.Equal(1, firstEvents.AttachCount);
|
||||||
|
Assert.Equal(2, firstEvents.DisposeCount);
|
||||||
|
Assert.Equal(0, commandFactoryCount);
|
||||||
|
|
||||||
|
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||||
|
Assert.Equal(2, eventFactoryCount);
|
||||||
|
Assert.Equal(1, commandFactoryCount);
|
||||||
|
controller.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
|
||||||
|
int eventFactoryCount = 0;
|
||||||
|
bool failCommands = true;
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ => eventFactoryCount++ == 0
|
||||||
|
? firstEvents
|
||||||
|
: new TestEventRouting(calls),
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
if (failCommands)
|
||||||
|
{
|
||||||
|
failCommands = false;
|
||||||
|
throw new InvalidOperationException("command failure");
|
||||||
|
}
|
||||||
|
return new TestCommandRouting(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
|
||||||
|
Assert.Equal(2, error.InnerExceptions.Count);
|
||||||
|
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
|
||||||
|
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
|
||||||
|
Assert.Equal(2, firstEvents.DisposeCount);
|
||||||
|
|
||||||
|
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||||
|
Assert.Equal(2, eventFactoryCount);
|
||||||
|
Assert.Equal(2, operations.Sessions.Count);
|
||||||
|
controller.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
var events = new TestEventRouting(calls)
|
||||||
|
{
|
||||||
|
FailuresRemaining = int.MaxValue,
|
||||||
|
};
|
||||||
|
int eventFactoryCount = 0;
|
||||||
|
int commandFactoryCount = 0;
|
||||||
|
LiveSessionHost host = CreateHost(
|
||||||
|
controller,
|
||||||
|
calls,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
eventFactoryCount++;
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
commandFactoryCount++;
|
||||||
|
throw new InvalidOperationException("command failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
LiveSessionStartResult first = host.Start(LiveOptions());
|
||||||
|
LiveSessionStartResult second = host.Start(LiveOptions());
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
|
||||||
|
Assert.Equal(3, events.DisposeCount);
|
||||||
|
Assert.Equal(1, eventFactoryCount);
|
||||||
|
Assert.Equal(1, commandFactoryCount);
|
||||||
|
Assert.Single(operations.Sessions);
|
||||||
|
Assert.Null(host.CurrentSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveSessionHost CreateHost(
|
||||||
|
LiveSessionController controller,
|
||||||
|
List<string> calls,
|
||||||
|
Func<WorldSession, ILiveSessionEventRouting> createEvents,
|
||||||
|
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
||||||
|
new(controller, new LiveSessionHostBindings(
|
||||||
|
Routing: new(createEvents, createCommands),
|
||||||
|
Reset: ResetBindings(() => calls.Add("reset")),
|
||||||
|
Selection: new(
|
||||||
|
id => calls.Add($"player:{id}"),
|
||||||
|
id => calls.Add($"vitals:{id}"),
|
||||||
|
id => calls.Add($"chat:{id}"),
|
||||||
|
id => calls.Add($"persistent:{id}"),
|
||||||
|
id => calls.Add($"vanish:{id}"),
|
||||||
|
() => calls.Add("clear-combat")),
|
||||||
|
EnteredWorld: new(
|
||||||
|
name => calls.Add($"active:{name}"),
|
||||||
|
() => calls.Add("restore-layout"),
|
||||||
|
() => calls.Add("sync-toolbar"),
|
||||||
|
name => calls.Add($"load-settings:{name}"),
|
||||||
|
() => calls.Add("arm-auto-entry")),
|
||||||
|
Connecting: (_, _, _) => calls.Add("connecting"),
|
||||||
|
Connected: () => calls.Add("connected")));
|
||||||
|
|
||||||
|
private static LiveSessionResetBindings ResetBindings(Action reset)
|
||||||
|
{
|
||||||
|
Action noop = static () => { };
|
||||||
|
return new LiveSessionResetBindings
|
||||||
|
{
|
||||||
|
MouseCapture = reset,
|
||||||
|
PlayerPresentation = noop,
|
||||||
|
TeleportTransit = noop,
|
||||||
|
SessionDialogs = noop,
|
||||||
|
ChatCommandTargets = noop,
|
||||||
|
SettingsCharacterContext = noop,
|
||||||
|
EquippedChildren = noop,
|
||||||
|
ExternalContainer = noop,
|
||||||
|
InteractionAndSelection = noop,
|
||||||
|
SelectionPresentation = noop,
|
||||||
|
ObjectTable = noop,
|
||||||
|
Spellbook = noop,
|
||||||
|
MagicRuntime = noop,
|
||||||
|
CombatAttack = noop,
|
||||||
|
CombatState = noop,
|
||||||
|
ItemMana = noop,
|
||||||
|
LocalPlayer = noop,
|
||||||
|
Friends = noop,
|
||||||
|
Squelch = noop,
|
||||||
|
TurbineChat = noop,
|
||||||
|
ParticleVisibility = noop,
|
||||||
|
InboundEventFifo = noop,
|
||||||
|
LiveLiveness = noop,
|
||||||
|
LiveRuntime = noop,
|
||||||
|
SessionIdentity = noop,
|
||||||
|
RemoteTeleport = noop,
|
||||||
|
NetworkEffects = noop,
|
||||||
|
AnimationHookFrames = noop,
|
||||||
|
LivePresentation = noop,
|
||||||
|
RemoteMovementDiagnostics = noop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
|
||||||
|
{
|
||||||
|
var environment = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ACDREAM_LIVE"] = live ? "1" : "0",
|
||||||
|
["ACDREAM_TEST_HOST"] = "127.0.0.1",
|
||||||
|
["ACDREAM_TEST_PORT"] = "9000",
|
||||||
|
["ACDREAM_TEST_USER"] = user,
|
||||||
|
["ACDREAM_TEST_PASS"] = "password",
|
||||||
|
};
|
||||||
|
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestEventRouting(List<string> calls)
|
||||||
|
: ILiveSessionEventRouting
|
||||||
|
{
|
||||||
|
public int FailuresRemaining { get; set; }
|
||||||
|
public int AttachFailuresRemaining { get; set; }
|
||||||
|
public int AttachCount { get; private set; }
|
||||||
|
public int DisposeCount { get; private set; }
|
||||||
|
|
||||||
|
public void Attach()
|
||||||
|
{
|
||||||
|
AttachCount++;
|
||||||
|
calls.Add("attach-events");
|
||||||
|
if (AttachFailuresRemaining > 0)
|
||||||
|
{
|
||||||
|
AttachFailuresRemaining--;
|
||||||
|
throw new InvalidOperationException("event attach failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCount++;
|
||||||
|
calls.Add("dispose-events");
|
||||||
|
if (FailuresRemaining > 0)
|
||||||
|
{
|
||||||
|
FailuresRemaining--;
|
||||||
|
throw new InvalidOperationException("event cleanup failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestCommandRouting(List<string> calls)
|
||||||
|
: ILiveSessionCommandRouting
|
||||||
|
{
|
||||||
|
public void Activate() => calls.Add("activate");
|
||||||
|
public void Publish<T>(T command) where T : notnull { }
|
||||||
|
public void Dispose() => calls.Add("dispose-commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
|
||||||
|
{
|
||||||
|
public List<WorldSession> Sessions { get; } = [];
|
||||||
|
|
||||||
|
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||||
|
{
|
||||||
|
calls.Add("resolve");
|
||||||
|
return new IPEndPoint(IPAddress.Loopback, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||||
|
{
|
||||||
|
calls.Add("create");
|
||||||
|
var session = new WorldSession(endpoint, new TestTransport());
|
||||||
|
Sessions.Add(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Connect(WorldSession session, string user, string password) =>
|
||||||
|
calls.Add("connect");
|
||||||
|
|
||||||
|
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
||||||
|
0u,
|
||||||
|
[
|
||||||
|
new CharacterList.Character(0x50000001u, "Grey", 10u),
|
||||||
|
new CharacterList.Character(0x50000002u, "Ready", 0u),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
11,
|
||||||
|
"Canonical",
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
|
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||||
|
calls.Add($"enter:{activeCharacterIndex}");
|
||||||
|
|
||||||
|
public void Tick(WorldSession session) { }
|
||||||
|
|
||||||
|
public void DisposeSession(WorldSession session)
|
||||||
|
{
|
||||||
|
calls.Add("dispose-session");
|
||||||
|
session.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestTransport : IWorldSessionTransport
|
||||||
|
{
|
||||||
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||||
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||||
|
|
||||||
|
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
|
||||||
|
{
|
||||||
|
from = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
using AcDream.App.Net;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
|
public sealed class LiveSessionSubscriptionSetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge()
|
||||||
|
{
|
||||||
|
var order = new List<int>();
|
||||||
|
var subscriptions = new LiveSessionSubscriptionSet();
|
||||||
|
subscriptions.Add(() => order.Add(1));
|
||||||
|
int secondFailures = 1;
|
||||||
|
subscriptions.Add(() =>
|
||||||
|
{
|
||||||
|
order.Add(2);
|
||||||
|
if (secondFailures-- > 0)
|
||||||
|
throw new InvalidOperationException("second failed");
|
||||||
|
});
|
||||||
|
int thirdFailures = 1;
|
||||||
|
subscriptions.Add(() =>
|
||||||
|
{
|
||||||
|
order.Add(3);
|
||||||
|
if (thirdFailures-- > 0)
|
||||||
|
throw new IOException("third failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
AggregateException error = Assert.Throws<AggregateException>(
|
||||||
|
subscriptions.Dispose);
|
||||||
|
|
||||||
|
Assert.Equal([3, 2, 1], order);
|
||||||
|
Assert.Collection(
|
||||||
|
error.InnerExceptions,
|
||||||
|
exception => Assert.IsType<IOException>(exception),
|
||||||
|
exception => Assert.IsType<InvalidOperationException>(exception));
|
||||||
|
|
||||||
|
subscriptions.Dispose();
|
||||||
|
subscriptions.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal([3, 2, 1, 3, 2], order);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges()
|
||||||
|
{
|
||||||
|
var order = new List<int>();
|
||||||
|
var subscriptions = new LiveSessionSubscriptionSet();
|
||||||
|
subscriptions.Add(() => order.Add(1));
|
||||||
|
subscriptions.Add(() =>
|
||||||
|
{
|
||||||
|
order.Add(2);
|
||||||
|
throw new InvalidOperationException("persistent");
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||||
|
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||||
|
|
||||||
|
Assert.Equal([2, 1, 2], order);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Normal file
112
tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using AcDream.App.Net;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
|
public sealed class RetailSkillFormulaTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
|
||||||
|
{
|
||||||
|
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
|
||||||
|
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
|
||||||
|
Assert.Equal(0u, invalidResult);
|
||||||
|
|
||||||
|
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
|
||||||
|
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
|
||||||
|
Assert.Equal(5u, validResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(9u, 4u, 2u)]
|
||||||
|
[InlineData(10u, 4u, 3u)]
|
||||||
|
[InlineData(11u, 4u, 3u)]
|
||||||
|
[InlineData(12u, 4u, 3u)]
|
||||||
|
public void DivisionRoundsToNearestWithExactHalvesUp(
|
||||||
|
uint numerator,
|
||||||
|
uint divisor,
|
||||||
|
uint expected)
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
|
||||||
|
|
||||||
|
Assert.True(RetailSkillFormula.TryCalculate(
|
||||||
|
formula,
|
||||||
|
numerator,
|
||||||
|
0u,
|
||||||
|
out uint result));
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdditiveWordIsInsideTheNumerator()
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
|
||||||
|
|
||||||
|
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
|
||||||
|
|
||||||
|
Assert.Equal(2u, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
|
||||||
|
|
||||||
|
Assert.True(RetailSkillFormula.TryCalculate(
|
||||||
|
formula,
|
||||||
|
uint.MaxValue,
|
||||||
|
0u,
|
||||||
|
out uint result));
|
||||||
|
|
||||||
|
Assert.Equal(0x80000000u, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
|
||||||
|
|
||||||
|
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
|
||||||
|
|
||||||
|
Assert.Equal(uint.MaxValue, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
|
||||||
|
{
|
||||||
|
const uint skillId = 0x345u;
|
||||||
|
const uint firstAttributeId = 1u;
|
||||||
|
const uint secondAttributeId = 2u;
|
||||||
|
var skillTable = new SkillTable();
|
||||||
|
skillTable.Skills.Add((SkillId)skillId, new SkillBase
|
||||||
|
{
|
||||||
|
Formula = new SkillFormula
|
||||||
|
{
|
||||||
|
AdditiveBonus = 1,
|
||||||
|
Attribute1Multiplier = 1,
|
||||||
|
Attribute2Multiplier = 2,
|
||||||
|
Divisor = 3,
|
||||||
|
Attribute1 = (AttributeId)firstAttributeId,
|
||||||
|
Attribute2 = (AttributeId)secondAttributeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var resolver = new LiveSkillCreditResolver(skillTable);
|
||||||
|
|
||||||
|
uint result = resolver.Resolve(
|
||||||
|
skillId,
|
||||||
|
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
|
||||||
|
|
||||||
|
Assert.Equal(3u, result);
|
||||||
|
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
|
||||||
|
{
|
||||||
|
AdditiveBonus = w,
|
||||||
|
Attribute1Multiplier = x,
|
||||||
|
Attribute2Multiplier = y,
|
||||||
|
Divisor = unchecked((int)z),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
{
|
{
|
||||||
string body = MethodBody(
|
string body = MethodBody(
|
||||||
"private void OnLoad()",
|
"private void OnLoad()",
|
||||||
"private AcDream.App.Net.LiveSessionLifecycleHost");
|
"private AcDream.App.Net.LiveSessionHost");
|
||||||
|
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
body,
|
body,
|
||||||
|
|
@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||||
"_renderFrameOrchestrator =",
|
"_renderFrameOrchestrator =",
|
||||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||||
"_liveSessionController.Start(");
|
"_liveSessionHost = CreateLiveSessionHost();",
|
||||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
|
"_liveSessionHost.Start(_options)");
|
||||||
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
|
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
|
||||||
|
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
|
||||||
string postStart = body[start..];
|
string postStart = body[start..];
|
||||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||||
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
|
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
|
public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge()
|
||||||
{
|
{
|
||||||
var order = new List<int>();
|
var order = new List<int>();
|
||||||
var subscriptions = new SubscriptionSet();
|
var subscriptions = new SubscriptionSet();
|
||||||
subscriptions.Add(() => order.Add(1));
|
subscriptions.Add(() => order.Add(1));
|
||||||
|
int secondFailures = 1;
|
||||||
subscriptions.Add(() =>
|
subscriptions.Add(() =>
|
||||||
{
|
{
|
||||||
order.Add(2);
|
order.Add(2);
|
||||||
throw new InvalidOperationException("second failed");
|
if (secondFailures-- > 0)
|
||||||
|
throw new InvalidOperationException("second failed");
|
||||||
});
|
});
|
||||||
|
int thirdFailures = 1;
|
||||||
subscriptions.Add(() =>
|
subscriptions.Add(() =>
|
||||||
{
|
{
|
||||||
order.Add(3);
|
order.Add(3);
|
||||||
throw new IOException("third failed");
|
if (thirdFailures-- > 0)
|
||||||
|
throw new IOException("third failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
AggregateException error = Assert.Throws<AggregateException>(
|
AggregateException error = Assert.Throws<AggregateException>(
|
||||||
|
|
@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests
|
||||||
error.InnerExceptions,
|
error.InnerExceptions,
|
||||||
exception => Assert.IsType<IOException>(exception),
|
exception => Assert.IsType<IOException>(exception),
|
||||||
exception => Assert.IsType<InvalidOperationException>(exception));
|
exception => Assert.IsType<InvalidOperationException>(exception));
|
||||||
|
|
||||||
subscriptions.Dispose();
|
subscriptions.Dispose();
|
||||||
|
subscriptions.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal([3, 2, 1, 3, 2], order);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases()
|
||||||
|
{
|
||||||
|
var order = new List<int>();
|
||||||
|
var subscriptions = new SubscriptionSet();
|
||||||
|
subscriptions.Add(() => order.Add(1));
|
||||||
|
subscriptions.Add(() =>
|
||||||
|
{
|
||||||
|
order.Add(2);
|
||||||
|
throw new InvalidOperationException("persistent");
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||||
|
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||||
|
|
||||||
|
Assert.Equal([2, 1, 2], order);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue