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
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
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
|
||||
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
|
||||
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
|
||||
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
|
||||
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||
Slice 8 checkpoints A–C are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
|
||||
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
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||
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
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
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
|
||||
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
|
||||
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
|
||||
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
|
||||
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||
Slice 8 checkpoints A–C are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
|
||||
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
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ What does NOT go here:
|
|||
## Current queue — 2026-07-22
|
||||
|
||||
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
||||
complete; `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed
|
||||
orchestration handoffs and the class is 4,666 lines / 196 fields / 70
|
||||
methods. Final Slice 8 composition/shutdown cleanup is active in
|
||||
complete; Slice 8 checkpoints A–C now own native callbacks and live-session
|
||||
composition outside the shell. `GameWindow.OnUpdate` and
|
||||
`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).
|
||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||
- **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
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -232,7 +232,9 @@ src/AcDream.App/
|
|||
│ └── Vfx/ # (already exists)
|
||||
├── Net/
|
||||
│ ├── 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
|
||||
├── Physics/
|
||||
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
||||
|
|
@ -436,7 +438,7 @@ useful ordering seam, but its ownership status is **partial**.
|
|||
| Area | Status | Current truth |
|
||||
|---|---|---|
|
||||
| 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`). |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 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.
|
||||
|
||||
---
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
|
||||
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
||||
preserve Slice 6 presentation. Final Slice 8 composition/shutdown cleanup is
|
||||
active. Issue #232 tracks process-residency variance in the soak without
|
||||
preserve Slice 6 presentation. Final Slice 8 checkpoints A–C are complete:
|
||||
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.
|
||||
|
||||
Carried:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ audit.
|
|||
test-facade residue.
|
||||
- [x] B — make native-window callback intake an explicit reversible owner and
|
||||
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.
|
||||
- [ ] D — extract retail world-environment/day/weather behavior.
|
||||
- [ ] 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
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
The behavior-preserving App ownership campaign is complete through Slice 7.
|
||||
`GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields /
|
||||
205 methods to 4,666 lines / 196 fields / 70 methods: 11,057 lines (70.3%) were
|
||||
removed without changing accepted gameplay or rendering behavior.
|
||||
The behavior-preserving App ownership campaign is complete through Slice 7 and
|
||||
Slice 8 checkpoints A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||
15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69
|
||||
methods: 11,134 lines (70.8%) were removed without changing accepted gameplay
|
||||
or rendering behavior.
|
||||
|
||||
| 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 |
|
||||
| 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`,
|
||||
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
||||
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
||||
|
|
@ -73,6 +83,9 @@ review rule live in `docs/architecture/code-structure.md`.
|
|||
|
||||
## 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.
|
||||
- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh
|
||||
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
|
||||
|
||||
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.
|
||||
It removes leftover feature state only where Slices 1–7 already established a
|
||||
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
|
||||
/// the displaced transport is disposed.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable
|
||||
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
|
|
|
|||
|
|
@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings(
|
|||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
/// remains in the supplied sinks; this class owns only routing and teardown.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionEventRouter : IDisposable
|
||||
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||
{
|
||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||
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 _accepting = 1;
|
||||
private int _accepting;
|
||||
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
|
||||
|
||||
public LiveSessionEventRouter(
|
||||
WorldSession session,
|
||||
|
|
@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
Validate(entities, environment, inventory, character, social);
|
||||
_session = session;
|
||||
_entities = entities;
|
||||
_environment = environment;
|
||||
_inventory = inventory;
|
||||
_character = character;
|
||||
_social = social;
|
||||
_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
|
||||
{
|
||||
|
|
@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
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);
|
||||
try
|
||||
{
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"live-session event routing failed and cleanup also failed",
|
||||
constructionError,
|
||||
cleanupError);
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
};
|
||||
|
||||
attach(handler);
|
||||
try
|
||||
{
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
}
|
||||
catch
|
||||
{
|
||||
detach(handler);
|
||||
throw;
|
||||
}
|
||||
// Add assumes cleanup ownership before it can call an external detach.
|
||||
// If the set is already closing, it retains/retries that exact edge;
|
||||
// calling detach again here would replay a successful removal.
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
|
@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : 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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
List<IDisposable>? subscriptions = _subscriptions;
|
||||
if (subscriptions is null)
|
||||
{
|
||||
subscription.Dispose();
|
||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||
}
|
||||
|
||||
subscriptions.Add(subscription);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
|
||||
if (subscriptions is null)
|
||||
return;
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int index = subscriptions.Count - 1; index >= 0; index--)
|
||||
for (int index = subscriptions.Length - 1; index >= 0; index--)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable
|
|||
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.
|
||||
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.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||
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
|
||||
// by ApplyLiveSessionEnteredWorld; ticked from
|
||||
// by LiveSessionHost's entered-world transition; ticked from
|
||||
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
||||
// other path that pre-empts the chase camera). Skipped entirely
|
||||
// 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
|
||||
// callbacks resolve this borrowed handle at call time and never cache it.
|
||||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
|
||||
private AcDream.Core.Net.WorldSession? LiveSession =>
|
||||
_liveSessionController?.CurrentSession;
|
||||
_liveSessionHost?.CurrentSession;
|
||||
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
||||
private int _liveCenterX => _liveWorldOrigin.CenterX;
|
||||
private int _liveCenterY => _liveWorldOrigin.CenterY;
|
||||
|
|
@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable
|
|||
try
|
||||
{
|
||||
// _activeToonKey is updated by
|
||||
// ApplyLiveSessionEnteredWorld
|
||||
// LiveSessionHost entered-world transition
|
||||
// so saving character settings always
|
||||
// writes under the chosen character's
|
||||
// name (or "default" pre-login).
|
||||
|
|
@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable
|
|||
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
||||
.DragItemOnPlayerOpensSecureTrade) != 0,
|
||||
toast: text => _debugVm?.AddToast(text),
|
||||
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
|
||||
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
|
||||
playerOnGround: GetDebugPlayerOnGround,
|
||||
inNonCombatMode: () => Combat.CurrentMode
|
||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||
|
|
@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable
|
|||
playerGuid: () => _playerServerGuid,
|
||||
activeToonName: () => _activeToonKey,
|
||||
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
||||
canSendRaise: () => _liveSessionController?.IsInWorld == true,
|
||||
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
|
||||
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
|
||||
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(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),
|
||||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
||||
canSend: () => _liveSessionController?.IsInWorld == true);
|
||||
canSend: () => _liveSessionHost?.IsInWorld == true);
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
|
||||
// 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),
|
||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||
retailChatVm,
|
||||
() => _liveSessionController?.Commands
|
||||
() => _liveSessionHost?.Commands
|
||||
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||
radarSnapshotProvider.BuildSnapshot,
|
||||
|
|
@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
||||
_playerModeAutoEntry),
|
||||
cameraFrame);
|
||||
_liveSessionHost = CreateLiveSessionHost();
|
||||
AcDream.App.Net.LiveSessionStartResult liveStart =
|
||||
_liveSessionController.Start(
|
||||
_options,
|
||||
CreateLiveSessionLifecycleHost());
|
||||
_liveSessionHost.Start(_options);
|
||||
switch (liveStart.Status)
|
||||
{
|
||||
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
|
||||
|
|
@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
|
||||
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
|
||||
Bind: CreateLiveSessionBinding,
|
||||
Reset: () =>
|
||||
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
|
||||
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
|
||||
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
|
||||
Routing: new(
|
||||
CreateLiveSessionEventRouter,
|
||||
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) =>
|
||||
Chat.OnSystemMessage(
|
||||
$"connecting to {host}:{port} as {user}",
|
||||
|
|
@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable
|
|||
Connected: () =>
|
||||
Chat.OnSystemMessage(
|
||||
"connected — character list received",
|
||||
chatType: 1),
|
||||
Selected: ApplyLiveSessionSelection,
|
||||
Entered: ApplyLiveSessionEnteredWorld));
|
||||
chatType: 1)));
|
||||
|
||||
private void ApplyLiveSessionSelection(
|
||||
AcDream.App.Net.LiveSessionCharacterSelection selection)
|
||||
private void LoadLiveSessionCharacterSettings(string characterName)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
var toonBag = _settingsStore.LoadCharacter(characterName);
|
||||
_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() =>
|
||||
AcDream.App.Net.LiveSessionResetManifest.Create(new()
|
||||
private AcDream.App.Net.LiveSessionResetBindings
|
||||
CreateLiveSessionResetBindings() => new()
|
||||
{
|
||||
MouseCapture = ResetSessionMouseCapture,
|
||||
PlayerPresentation = ResetSessionPlayerPresentation,
|
||||
|
|
@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable
|
|||
AnimationHookFrames = () => _animationHookFrames?.Clear(),
|
||||
LivePresentation = () => _liveEntityPresentation?.Clear(),
|
||||
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
|
||||
});
|
||||
};
|
||||
|
||||
private void ResetSessionMouseCapture()
|
||||
=> _gameplayInputFrame?.ResetSession();
|
||||
|
|
@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
|
||||
AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
|
|
@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine);
|
||||
}
|
||||
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
||||
try
|
||||
{
|
||||
events = new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
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;
|
||||
}
|
||||
return new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveInventorySessionBindings
|
||||
|
|
@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private AcDream.App.Net.LiveCharacterSessionBindings
|
||||
CreateLiveCharacterSessionBindings(
|
||||
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
|
||||
DatReaderWriter.DBObjs.SkillTable? skillTable)
|
||||
{
|
||||
var skillCreditResolver =
|
||||
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
|
||||
return new(
|
||||
Combat,
|
||||
SpellBook,
|
||||
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
|
||||
{
|
||||
// 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;
|
||||
},
|
||||
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
|
||||
|
|
@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable
|
|||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
ClientTime: ClientTimerNow);
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveSocialSessionBindings
|
||||
CreateLiveSocialSessionBindings() => new(
|
||||
|
|
@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable
|
|||
// optional SettingsPanel and its visibility/input operations.
|
||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
|
||||
// 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
|
||||
// "default" and gets swapped to the actual character name on the
|
||||
// first EnterWorld.
|
||||
|
|
@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable
|
|||
_persistedGameplay = _settingsStore.LoadGameplay();
|
||||
_persistedChat = _settingsStore.LoadChat();
|
||||
// _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.
|
||||
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
|
||||
|
|
@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable
|
|||
private void ToggleLiveCombatMode()
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
||||
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||
return;
|
||||
|
||||
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
|
||||
|
|
@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable
|
|||
private void UseItemByGuid(uint guid)
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
||||
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||
return;
|
||||
uint sequence = session.NextGameActionSequence();
|
||||
session.SendGameAction(
|
||||
|
|
@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
if (ReferenceEquals(_liveSessionController, controller))
|
||||
{
|
||||
_liveSessionHost = null;
|
||||
_liveSessionController = null;
|
||||
}
|
||||
}),
|
||||
]),
|
||||
// Frame composition borrows equipped, effect, audio, and render
|
||||
|
|
|
|||
|
|
@ -7,36 +7,48 @@ namespace AcDream.Core.Net;
|
|||
/// </summary>
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
List<IDisposable>? subscriptions = _subscriptions;
|
||||
if (subscriptions is null)
|
||||
{
|
||||
subscription.Dispose();
|
||||
throw new ObjectDisposedException(nameof(SubscriptionSet));
|
||||
}
|
||||
|
||||
subscriptions.Add(subscription);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
public void Add(Action 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()
|
||||
{
|
||||
List<IDisposable>? subscriptions =
|
||||
Interlocked.Exchange(ref _subscriptions, null);
|
||||
if (subscriptions is null)
|
||||
return;
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int i = subscriptions.Count - 1; i >= 0; i--)
|
||||
for (int i = subscriptions.Length - 1; i >= 0; i--)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable
|
|||
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;
|
||||
|
||||
[Fact]
|
||||
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
|
||||
public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession()
|
||||
{
|
||||
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
|
||||
|
||||
|
|
@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
|||
fields,
|
||||
field => field.Name == "_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.FieldType == typeof(WorldSession));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan));
|
||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
|
||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
|
||||
}
|
||||
|
|
@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
|||
[InlineData("ClearInboundEntityState")]
|
||||
[InlineData("WireLiveSessionEvents")]
|
||||
[InlineData("DisposeLiveSessionRouting")]
|
||||
[InlineData("CreateLiveSessionBinding")]
|
||||
[InlineData("ApplyLiveSessionSelection")]
|
||||
[InlineData("ApplyLiveSessionEnteredWorld")]
|
||||
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
|
||||
{
|
||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net;
|
|||
|
||||
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]
|
||||
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
||||
{
|
||||
|
|
@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests
|
|||
Counters counters,
|
||||
Action<int>? constructionCheckpoint = null,
|
||||
CombatState? combat = null,
|
||||
ChatLog? chat = null) => new(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
PickedUp: _ => { },
|
||||
MotionUpdated: _ => { },
|
||||
PositionUpdated: _ => { },
|
||||
VectorUpdated: _ => { },
|
||||
StateUpdated: _ => { },
|
||||
ParentUpdated: _ => { },
|
||||
TeleportStarted: _ => counters.Teleport++,
|
||||
AppearanceUpdated: _ => { },
|
||||
PlayPhysicsScript: _ => { },
|
||||
PlayPhysicsScriptType: _ => { }),
|
||||
new LiveEnvironmentSessionSink(
|
||||
EnvironChanged: _ => { },
|
||||
ServerTimeUpdated: _ =>
|
||||
{
|
||||
if (counters.ThrowOnServerTime)
|
||||
throw new InvalidOperationException("injected sink failure");
|
||||
counters.ServerTime++;
|
||||
}),
|
||||
NewInventoryBindings(),
|
||||
NewCharacterBindings(combat),
|
||||
NewSocialBindings(chat),
|
||||
constructionCheckpoint);
|
||||
ChatLog? chat = null)
|
||||
{
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
PickedUp: _ => { },
|
||||
MotionUpdated: _ => { },
|
||||
PositionUpdated: _ => { },
|
||||
VectorUpdated: _ => { },
|
||||
StateUpdated: _ => { },
|
||||
ParentUpdated: _ => { },
|
||||
TeleportStarted: _ => counters.Teleport++,
|
||||
AppearanceUpdated: _ => { },
|
||||
PlayPhysicsScript: _ => { },
|
||||
PlayPhysicsScriptType: _ => { }),
|
||||
new LiveEnvironmentSessionSink(
|
||||
EnvironChanged: _ => { },
|
||||
ServerTimeUpdated: _ =>
|
||||
{
|
||||
if (counters.ThrowOnServerTime)
|
||||
throw new InvalidOperationException("injected sink failure");
|
||||
counters.ServerTime++;
|
||||
}),
|
||||
NewInventoryBindings(),
|
||||
NewCharacterBindings(combat),
|
||||
NewSocialBindings(chat),
|
||||
constructionCheckpoint);
|
||||
try
|
||||
{
|
||||
router.Attach();
|
||||
return router;
|
||||
}
|
||||
catch
|
||||
{
|
||||
router.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
||||
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(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionLifecycleHost");
|
||||
"private AcDream.App.Net.LiveSessionHost");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
|
|
@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||
"_renderFrameOrchestrator =",
|
||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||
"_liveSessionController.Start(");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
|
||||
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
|
||||
"_liveSessionHost = CreateLiveSessionHost();",
|
||||
"_liveSessionHost.Start(_options)");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
|
||||
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
|
||||
string postStart = body[start..];
|
||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
|
||||
|
|
|
|||
|
|
@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
|
||||
public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new SubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
int secondFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("second failed");
|
||||
if (secondFailures-- > 0)
|
||||
throw new InvalidOperationException("second failed");
|
||||
});
|
||||
int thirdFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
throw new IOException("third failed");
|
||||
if (thirdFailures-- > 0)
|
||||
throw new IOException("third failed");
|
||||
});
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(
|
||||
|
|
@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests
|
|||
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_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