Bite-sized TDD plan for the retail teleport flow. Slice 1: pure TeleportAnimSequencer (7-state TAS) + golden-timing tests. Slice 2: the #145 readiness-gate fix (IsLandblockLoaded + Decide outdoorReady axis + apparatus probe) — ships independently of the visuals. Slice 3: TeleportFlowController (delegate-injected, unit-tested) + TeleportFadeOverlay + portal wiring (PlaceTeleportArrival split: place vs InWorld so the input lock persists the whole animation). Slice 4: one yaw-freeze + portal sounds via the EnumIDMap chain. Slice 5: de-dup login readiness onto Decide, route login/death through the controller, logout (Shift+Esc) + 0xF653 + disconnect, remove dead _teleportArrival plumbing. Slice 6 (literal 3D swirl) is a follow-up plan gated on a cdb asset trace. Drafted via two research + drafting workflows; slices 3-5 redrafted as one cohesive unit against a pinned controller API after the first parallel pass produced cross-slice inconsistencies (missing controller task, triplicated yaw-freeze, a fabricated PlayerMovementController.Update signature). All load-bearing signatures personally verified against the tree. Plan: docs/superpowers/plans/2026-06-21-retail-teleport-flow.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2315 lines
112 KiB
Markdown
2315 lines
112 KiB
Markdown
# Retail Teleport Flow Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the retail-faithful unified teleport flow — a `TeleportAnimState` (TAS) animation state machine + arrival hold that covers **login, logout, death, and portal** with a fade-to-black cover, **holds until the destination landblock is actually loaded** (folding in the #145 unstreamed-arrival cascade fix), and **locks input + camera for the whole transition**.
|
||
|
||
**Architecture:** A pure Core state machine (`TeleportAnimSequencer`, the 7-state TAS machine) + a thin App orchestrator (`TeleportFlowController`) that wraps it and drives all side effects through injected delegates + a `TeleportFadeOverlay` `UiElement` for the black fade. The readiness gate (`PhysicsEngine.IsLandblockLoaded`) makes outdoor arrivals hold until the collision landblock is resident. Slices 1–2 land the sequencer + readiness gate (the **#145 fix ships independently of the visuals**); slices 3–5 route portal/login/death/logout through one controller and de-dup the duplicated readiness/recenter logic; **slice 6 (the literal 3D portal swirl) is a follow-up plan** gated on a cdb asset trace (see "Slice 6" at the end).
|
||
|
||
**Tech Stack:** C# .NET 10, xunit (`dotnet test`), Silk.NET (GL / OpenAL / input), DatReaderWriter (dat access), the existing `AcDream.Core` / `AcDream.App` / `AcDream.UI.Abstractions` layers. Decomp oracle: `docs/research/named-retail/`. Design doc this plan implements: [`docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md`](../specs/2026-06-21-retail-teleport-flow-design.md).
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
| Path | Responsibility | Slice |
|
||
|---|---|---|
|
||
| `src/AcDream.Core/World/TeleportAnimSequencer.cs` (new) | Pure 7-state TAS machine: enums, `TeleportAnimSnapshot`, the sequencer. No GL/dat/net. | 1 |
|
||
| `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` (new) | Golden-timing unit tests for the sequencer. | 1 |
|
||
| `src/AcDream.Core/Physics/PhysicsEngine.cs` (modify) | Add `IsLandblockLoaded(uint)`. | 2 |
|
||
| `src/AcDream.App/World/TeleportArrivalController.cs` (modify) | Extend `TeleportArrivalRules.Decide` with the `outdoorReady` axis. | 2 |
|
||
| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (modify) | `ProbeArrivalGateEnabled` flag for the apparatus probe. | 2 |
|
||
| `src/AcDream.App/World/TeleportFlowController.cs` (new) | The TAS orchestrator: wraps the sequencer, drives side effects via delegates. | 3 |
|
||
| `src/AcDream.App/UI/TeleportFadeOverlay.cs` (new) | Full-screen black alpha-quad `UiElement`, driven by `Snapshot.FadeAlpha`. | 3 |
|
||
| `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (new) | Delegate-dispatch unit tests (spy delegates + stub readiness). | 3 |
|
||
| `tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs` (new) | Alpha→Visible + fill-color unit tests. | 3 |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs` (modify) | Wiring only (god object — no feature bodies): construct + tick the controller, split `PlaceTeleportArrival`, the four entry points, the yaw-freeze, the portal sounds, the login de-dup, the logout command. | 3,4,5 |
|
||
| `docs/architecture/retail-divergence-register.md` (modify) | The four deviation rows (§5 of the design). | 1,3,5 |
|
||
|
||
## Architecture — the pinned controller design (read before slices 3–5)
|
||
|
||
The relationship between the pieces is **load-bearing**; the slices assume exactly this shape:
|
||
|
||
- **`TeleportAnimSequencer` (pure, Slice 1)** is the 7-state machine. It holds in `Tunnel` while `!worldReady` (no internal timeout), emits `Place` when it leaves `Tunnel`, and `FireLoginComplete` when it reaches `Off`. `FadeAlpha` is 0 inside the tunnel states and smoothsteps on the fade states.
|
||
- **`TeleportFlowController` (Slice 3)** owns the sequencer and **all timing/lock/placement decisions**. Each `Tick(dt)` it computes `worldReady` from the injected readiness probe (`Ready` ∨ `Impossible` ∨ frame-timeout, else hold; **Logout has no destination so it is intrinsically ready**), ticks the sequencer, and dispatches the sequencer's events to injected delegates: `onPlace` (place the player), `onLoginComplete` (flip `PortalSpace→InWorld` + send GameAction `0xA1`), `onLogoutDisconnect`, `onPlayEnterSound`, `onPlayExitSound`.
|
||
- **The input lock (`PlayerState.PortalSpace`) persists the WHOLE animation.** It is set at the entry point and cleared **only in `onLoginComplete`** (sequencer reached `Off`) — *not* at placement. This is why `PlaceTeleportArrival` is **split** in Slice 3: the placement half (Resolve + SetPosition + camera) runs on the `Place` event; the `State=InWorld` + `LoginComplete` half runs at `Off`.
|
||
- **`Begin(kind, destPos, destCell)` is idempotent on the sequencer.** Portal triggers it twice — once with no coords (`OnTeleportStarted`) and once with coords (`OnLivePositionUpdated`); the second call fills the destination without restarting the animation.
|
||
- **Entry points:** Portal/Death enter at `Tunnel` (await coords → `onPlace`); Login enters at `Tunnel` already-placed (readiness returns `Ready` immediately → fast exit; `onPlace` re-resolves harmlessly); Logout enters at `WorldFadeOut` and ends in `onLogoutDisconnect`.
|
||
|
||
**Cross-slice conventions (so the slices compose):**
|
||
1. The 4th `Decide` arg is named **`outdoorReady`** everywhere (terrain-ready for login, landblock-ready for teleport).
|
||
2. The **yaw-freeze lives only in Slice 4** (one early-return covering both MMB look + RMB orbit); it is *not* repeated in slices 3 or 5.
|
||
3. **`PlaceTeleportArrival` is split exactly once** (Slice 3) into `PlaceTeleportArrivalCore` + `OnTeleportLoginComplete`; slices 4/5 consume those.
|
||
4. The portal sound enum is **`DatReaderWriter.Enums.Sound.EnterPortal`/`ExitPortal`** (verified — *not* `UI_EnterPortal=0x6A`); the Core `SoundId` enum is untouched.
|
||
5. The old `TeleportArrivalController` (the bare hold) becomes unused once the portal path routes through `_teleportFlow`; its field/plumbing removal is deferred to the Slice 5 de-dup commit to avoid a half-removed field mid-plan.
|
||
|
||
---
|
||
|
||
### Slice 1: TeleportAnimSequencer — pure 7-state machine + unit tests
|
||
|
||
**Placement decision:** `src/AcDream.Core/World/TeleportAnimSequencer.cs` in `namespace AcDream.Core.World`. Core already owns `src/AcDream.Core/World/` (DerethDateTime, WorldEntity, etc.) and carries no GL/net dependency. The enums and record from the shared-type contract land here alongside the sequencer. Tests in `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`.
|
||
|
||
---
|
||
|
||
### Task 1.1: Create the types file (enums + record)
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
|
||
- Test: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test** — the test file references `TeleportAnimState`, `TeleportEntryKind`, `TeleportAnimEvent`, and `TeleportAnimSnapshot` in `AcDream.Core.World`; the test project already references `AcDream.Core` (`tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj:31`).
|
||
|
||
```csharp
|
||
// tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.Core.Tests.World;
|
||
|
||
public sealed class TeleportAnimSequencerTests
|
||
{
|
||
// --- Task 1.1: type presence ---
|
||
|
||
[Fact]
|
||
public void Enums_HaveExpectedValues()
|
||
{
|
||
Assert.Equal(0, (int)TeleportAnimState.Off);
|
||
Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut);
|
||
Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn);
|
||
Assert.Equal(3, (int)TeleportAnimState.Tunnel);
|
||
Assert.Equal(4, (int)TeleportAnimState.TunnelContinue);
|
||
Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut);
|
||
Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn);
|
||
|
||
_ = TeleportEntryKind.Portal;
|
||
_ = TeleportEntryKind.Login;
|
||
_ = TeleportEntryKind.Death;
|
||
_ = TeleportEntryKind.Logout;
|
||
|
||
_ = TeleportAnimEvent.PlayEnterSound;
|
||
_ = TeleportAnimEvent.PlayExitSound;
|
||
_ = TeleportAnimEvent.Place;
|
||
_ = TeleportAnimEvent.FireLoginComplete;
|
||
_ = TeleportAnimEvent.EnterTunnel;
|
||
}
|
||
|
||
[Fact]
|
||
public void Snapshot_DefaultsAreOff_ClearAlpha()
|
||
{
|
||
var snap = new TeleportAnimSnapshot(
|
||
TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
|
||
Assert.Equal(TeleportAnimState.Off, snap.State);
|
||
Assert.Equal(0f, snap.FadeAlpha);
|
||
Assert.False(snap.ShowTunnel);
|
||
Assert.False(snap.ShowPleaseWait);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Enums_HaveExpectedValues"
|
||
```
|
||
Expected: FAIL — `TeleportAnimState` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement** — create the types (no sequencer class body yet, that comes in Task 1.2):
|
||
|
||
```csharp
|
||
// src/AcDream.Core/World/TeleportAnimSequencer.cs
|
||
namespace AcDream.Core.World;
|
||
|
||
// acclient.h:6871 — TeleportAnimState enum, verbatim order.
|
||
public enum TeleportAnimState
|
||
{
|
||
Off = 0,
|
||
WorldFadeOut = 1,
|
||
TunnelFadeIn = 2,
|
||
Tunnel = 3,
|
||
TunnelContinue = 4,
|
||
TunnelFadeOut = 5,
|
||
WorldFadeIn = 6,
|
||
}
|
||
|
||
/// <summary>Why the teleport was triggered — drives per-entry start state (spec §2.5).</summary>
|
||
public enum TeleportEntryKind { Portal, Login, Death, Logout }
|
||
|
||
/// <summary>
|
||
/// Edge-triggered events the sequencer emits on the tick they first occur.
|
||
/// Consumers drive audio / placement / LoginComplete from these; the sequencer
|
||
/// has no dependency on any of those systems.
|
||
/// </summary>
|
||
public enum TeleportAnimEvent
|
||
{
|
||
PlayEnterSound, // Begin(): sound_ui_enter_portal
|
||
EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden
|
||
Place, // Tunnel -> TunnelContinue: world loaded; place the player
|
||
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
|
||
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
|
||
}
|
||
|
||
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
|
||
public readonly record struct TeleportAnimSnapshot(
|
||
TeleportAnimState State,
|
||
float FadeAlpha, // 0 = clear world, 1 = full black
|
||
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
|
||
bool ShowPleaseWait); // true during TunnelContinue only
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
```
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.2: Sequencer skeleton + Begin(), IsActive, initial state
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
// Add to TeleportAnimSequencerTests:
|
||
|
||
[Theory]
|
||
[InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)]
|
||
[InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)]
|
||
[InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)]
|
||
[InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)]
|
||
public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart)
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
Assert.False(seq.IsActive);
|
||
|
||
seq.Begin(kind);
|
||
|
||
Assert.True(seq.IsActive);
|
||
Assert.Equal(expectedStart, seq.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void Begin_EmitsPlayEnterSoundOnFirstTick()
|
||
{
|
||
// PlayEnterSound is edge-triggered on the first Tick after Begin.
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
var (_, events) = seq.Tick(dt: 0f, worldReady: false);
|
||
Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Begin_SetsCorrectStartState"
|
||
```
|
||
Expected: FAIL — `TeleportAnimSequencer` class missing.
|
||
|
||
- [ ] **Step 3: Implement** — add class body to the same file (golden constants + Begin + Tick stub):
|
||
|
||
```csharp
|
||
// Append to src/AcDream.Core/World/TeleportAnimSequencer.cs
|
||
|
||
/// <summary>
|
||
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
|
||
/// No GL / dat / network dependency — fully unit-testable.
|
||
/// </summary>
|
||
public sealed class TeleportAnimSequencer
|
||
{
|
||
// Golden constants (spec §2.1, verified VAs):
|
||
// TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s
|
||
// TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s
|
||
// TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s
|
||
public const float FadeTime = 1.0f;
|
||
public const float MinContinue = 2.0f;
|
||
public const float MaxContinue = 5.0f;
|
||
|
||
private TeleportAnimState _state = TeleportAnimState.Off;
|
||
private float _elapsed = 0f; // time in current state
|
||
private bool _enterSoundPending = false; // edge: fire PlayEnterSound on next Tick
|
||
|
||
public bool IsActive => _state != TeleportAnimState.Off;
|
||
public TeleportAnimState State => _state;
|
||
|
||
/// <summary>
|
||
/// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
|
||
/// Logout enters at WorldFadeOut (spec §2.5).
|
||
/// </summary>
|
||
public void Begin(TeleportEntryKind kind)
|
||
{
|
||
_state = kind == TeleportEntryKind.Logout
|
||
? TeleportAnimState.WorldFadeOut
|
||
: TeleportAnimState.Tunnel;
|
||
_elapsed = 0f;
|
||
_enterSoundPending = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance the machine by <paramref name="dt"/> seconds.
|
||
/// <paramref name="worldReady"/> = destination collision landblock is resident.
|
||
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
||
/// </summary>
|
||
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
||
Tick(float dt, bool worldReady)
|
||
{
|
||
// Full implementation in Task 1.3 – stub returns current state with no transitions.
|
||
var evts = new List<TeleportAnimEvent>();
|
||
if (_enterSoundPending)
|
||
{
|
||
evts.Add(TeleportAnimEvent.PlayEnterSound);
|
||
_enterSoundPending = false;
|
||
}
|
||
_elapsed += dt;
|
||
var snap = BuildSnapshot();
|
||
return (snap, evts);
|
||
}
|
||
|
||
private TeleportAnimSnapshot BuildSnapshot()
|
||
{
|
||
float alpha = ComputeFadeAlpha(_state, _elapsed);
|
||
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
|
||
or TeleportAnimState.Tunnel
|
||
or TeleportAnimState.TunnelContinue
|
||
or TeleportAnimState.TunnelFadeOut;
|
||
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
|
||
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
|
||
}
|
||
|
||
private static float Smoothstep(float t)
|
||
{
|
||
t = Math.Clamp(t, 0f, 1f);
|
||
return t * t * (3f - 2f * t);
|
||
}
|
||
|
||
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
|
||
{
|
||
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
|
||
return state switch
|
||
{
|
||
// Fading TO black (alpha 0→1):
|
||
TeleportAnimState.WorldFadeOut => Smoothstep(t),
|
||
TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
|
||
// Full black / fully clear inside tunnel states:
|
||
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
|
||
TeleportAnimState.TunnelContinue => 0f,
|
||
// Fading back out of tunnel:
|
||
TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black
|
||
TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears
|
||
_ => 0f,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
```
|
||
Expected: PASS (all tests so far).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.3: State transitions — timing-correct Tick() implementation
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests** — drive the machine through each transition with exact elapsed times:
|
||
|
||
```csharp
|
||
// Add to TeleportAnimSequencerTests:
|
||
|
||
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
|
||
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
|
||
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
|
||
{
|
||
var allEvts = new List<TeleportAnimEvent>();
|
||
float remaining = seconds;
|
||
TeleportAnimSnapshot last = default;
|
||
while (remaining > 0f)
|
||
{
|
||
float dt = Math.Min(step, remaining);
|
||
var (snap, evts) = seq.Tick(dt, worldReady);
|
||
last = snap;
|
||
allEvts.AddRange(evts);
|
||
remaining -= dt;
|
||
}
|
||
return (last, allEvts);
|
||
}
|
||
|
||
// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---
|
||
|
||
[Fact]
|
||
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
|
||
// Consume the enter-sound tick at dt=0
|
||
seq.Tick(0f, worldReady: false);
|
||
|
||
// Drive just past FadeTime (1.0s)
|
||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
||
|
||
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void Logout_After2xFadeTime_TransitionsToTunnel()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||
|
||
DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
||
|
||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||
}
|
||
|
||
// --- Portal/Login/Death path: enters at Tunnel ---
|
||
|
||
[Fact]
|
||
public void Portal_StartsInTunnel_HoldsWhileNotReady()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||
|
||
// Drive 10s — should not advance past Tunnel while !worldReady
|
||
DriveSeconds(seq, 10f, worldReady: false);
|
||
|
||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||
|
||
// The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
|
||
var (_, evts) = seq.Tick(0.016f, worldReady: true);
|
||
|
||
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||
Assert.Contains(TeleportAnimEvent.Place, evts);
|
||
}
|
||
|
||
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
|
||
|
||
[Fact]
|
||
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
// Enter TunnelContinue
|
||
seq.Tick(0.016f, worldReady: true);
|
||
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||
|
||
// Drive MinContinue - ε — should still be TunnelContinue
|
||
DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);
|
||
|
||
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||
|
||
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||
|
||
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||
}
|
||
|
||
// --- MAX_CONTINUE forces progress even when !worldReady ---
|
||
|
||
[Fact]
|
||
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
|
||
{
|
||
// Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
|
||
// This path exercises the safety-net from spec §3.4.
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
// Manually push the state to TunnelContinue by passing worldReady=true for one tick,
|
||
// then simulate worldReady toggling back to false.
|
||
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||
|
||
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
|
||
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
|
||
|
||
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||
}
|
||
|
||
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
|
||
|
||
[Fact]
|
||
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||
|
||
// Drive through MinContinue -> TunnelFadeOut
|
||
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||
|
||
// Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
|
||
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
||
Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
|
||
Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
|
||
}
|
||
|
||
// --- WorldFadeIn -> Off: FireLoginComplete edge event ---
|
||
|
||
[Fact]
|
||
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn
|
||
|
||
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
||
|
||
Assert.Equal(TeleportAnimState.Off, seq.State);
|
||
Assert.False(seq.IsActive);
|
||
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_WhenWorldReady_TransitionsToTunnelContinue"
|
||
```
|
||
Expected: FAIL — Tick() stub never advances state.
|
||
|
||
- [ ] **Step 3: Implement** — replace the `Tick()` stub in `TeleportAnimSequencer` with the full transition machine. Also tracks elapsed-in-state and `_continueElapsed` for the MIN/MAX gates:
|
||
|
||
```csharp
|
||
// Replace the existing Tick() and add _continueElapsed field in TeleportAnimSequencer:
|
||
|
||
private float _continueElapsed = 0f; // tracks time inside TunnelContinue
|
||
private bool _exitSoundPending = false;
|
||
private bool _loginCompletePending = false;
|
||
private bool _enterTunnelPending = false;
|
||
|
||
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
||
Tick(float dt, bool worldReady)
|
||
{
|
||
var evts = new List<TeleportAnimEvent>();
|
||
|
||
// Edge events queued by Begin or a prior transition:
|
||
if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; }
|
||
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
|
||
if (_exitSoundPending) { evts.Add(TeleportAnimEvent.PlayExitSound); _exitSoundPending = false; }
|
||
if (_loginCompletePending){ evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; }
|
||
|
||
_elapsed += dt;
|
||
|
||
// State-machine transitions:
|
||
switch (_state)
|
||
{
|
||
case TeleportAnimState.WorldFadeOut:
|
||
if (_elapsed >= FadeTime)
|
||
Advance(TeleportAnimState.TunnelFadeIn, evts, enterTunnel: false);
|
||
break;
|
||
|
||
case TeleportAnimState.TunnelFadeIn:
|
||
if (_elapsed >= FadeTime)
|
||
Advance(TeleportAnimState.Tunnel, evts, enterTunnel: true);
|
||
break;
|
||
|
||
case TeleportAnimState.Tunnel:
|
||
// Hold here until worldReady (EndTeleportAnimation analogue).
|
||
if (worldReady)
|
||
{
|
||
evts.Add(TeleportAnimEvent.Place);
|
||
Advance(TeleportAnimState.TunnelContinue, evts, enterTunnel: false);
|
||
_continueElapsed = 0f;
|
||
}
|
||
break;
|
||
|
||
case TeleportAnimState.TunnelContinue:
|
||
_continueElapsed += dt;
|
||
bool minMet = _continueElapsed >= MinContinue;
|
||
bool maxForce = _continueElapsed >= MaxContinue;
|
||
if (minMet || maxForce)
|
||
Advance(TeleportAnimState.TunnelFadeOut, evts, enterTunnel: false);
|
||
break;
|
||
|
||
case TeleportAnimState.TunnelFadeOut:
|
||
if (_elapsed >= FadeTime)
|
||
{
|
||
_exitSoundPending = true;
|
||
Advance(TeleportAnimState.WorldFadeIn, evts, enterTunnel: false);
|
||
// Flush the exit sound in the same tick:
|
||
evts.Add(TeleportAnimEvent.PlayExitSound);
|
||
_exitSoundPending = false;
|
||
}
|
||
break;
|
||
|
||
case TeleportAnimState.WorldFadeIn:
|
||
if (_elapsed >= FadeTime)
|
||
{
|
||
_loginCompletePending = true;
|
||
Advance(TeleportAnimState.Off, evts, enterTunnel: false);
|
||
evts.Add(TeleportAnimEvent.FireLoginComplete);
|
||
_loginCompletePending = false;
|
||
}
|
||
break;
|
||
|
||
case TeleportAnimState.Off:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return (BuildSnapshot(), evts);
|
||
}
|
||
|
||
private void Advance(TeleportAnimState next, List<TeleportAnimEvent> evts, bool enterTunnel)
|
||
{
|
||
_state = next;
|
||
_elapsed = 0f;
|
||
if (enterTunnel) _enterTunnelPending = true;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
```
|
||
Expected: PASS (all transition tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.4: FadeAlpha endpoints and monotonicity
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Add to TeleportAnimSequencerTests:
|
||
|
||
[Fact]
|
||
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
|
||
// At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible).
|
||
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
|
||
}
|
||
|
||
[Fact]
|
||
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
|
||
|
||
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
|
||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
||
|
||
var (snap, _) = seq.Tick(0f, worldReady: false);
|
||
// smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1
|
||
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
|
||
}
|
||
|
||
[Fact]
|
||
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
seq.Tick(0f, worldReady: false);
|
||
|
||
float prev = -1f;
|
||
// Sample 50 steps within FadeTime
|
||
float step = TeleportAnimSequencer.FadeTime / 50f;
|
||
for (int i = 0; i < 48; i++) // stop before transition
|
||
{
|
||
var (snap, _) = seq.Tick(step, worldReady: false);
|
||
if (seq.State != TeleportAnimState.WorldFadeOut) break;
|
||
Assert.True(snap.FadeAlpha >= prev - 0.001f,
|
||
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
|
||
prev = snap.FadeAlpha;
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void FadeAlpha_IsZero_DuringTunnelStates()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
|
||
// In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed)
|
||
var (snap, _) = seq.Tick(0.016f, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||
Assert.Equal(0f, snap.FadeAlpha);
|
||
}
|
||
|
||
[Fact]
|
||
public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise()
|
||
{
|
||
// Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Logout);
|
||
seq.Tick(0f, worldReady: false);
|
||
|
||
// WorldFadeOut: ShowTunnel = false
|
||
var (snap, _) = seq.Tick(0.016f, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
||
Assert.False(snap.ShowTunnel);
|
||
|
||
// Advance to TunnelFadeIn
|
||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
|
||
var (snap2, _) = seq.Tick(0f, worldReady: false);
|
||
Assert.True(snap2.ShowTunnel);
|
||
|
||
// Advance to Tunnel
|
||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||
var (snap3, _) = seq.Tick(0f, worldReady: false);
|
||
Assert.True(snap3.ShowTunnel);
|
||
}
|
||
|
||
[Fact]
|
||
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
seq.Tick(0f, worldReady: false);
|
||
|
||
// Tunnel: ShowPleaseWait false
|
||
var (snap1, _) = seq.Tick(0.016f, worldReady: false);
|
||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||
Assert.False(snap1.ShowPleaseWait);
|
||
|
||
// Advance to TunnelContinue
|
||
var (snap2, _) = seq.Tick(0.016f, worldReady: true);
|
||
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||
Assert.True(snap2.ShowPleaseWait);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.FadeAlpha_IsZeroAtStart_OfWorldFadeOut"
|
||
```
|
||
Expected: FAIL — `ComputeFadeAlpha` result not yet connected through `Tick` (the stub returned 0 unconditionally, Task 1.2 hadn't wired `BuildSnapshot` into the real state in `Tick`). After Task 1.3 landed, the alpha logic is correct — this test validates it runs through the right smoothstep path. (If Task 1.3 is already green, these may pass immediately; the step 2 confirms they exercise the code path.)
|
||
|
||
- [ ] **Step 3: Implement** — `ComputeFadeAlpha` is already implemented in Task 1.2; no new implementation needed. The only refinement: ensure `_elapsed` is sampled correctly relative to the current state's entry time (already reset to 0f in `Advance`). No code changes expected if Task 1.3 is green.
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
```
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "test(core/slice-1): FadeAlpha endpoint + monotonicity + ShowTunnel/ShowPleaseWait coverage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.5: Full portal event sequence and logout event sequence
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests** — exercise the complete ordered event stream for each major path:
|
||
|
||
```csharp
|
||
// Add to TeleportAnimSequencerTests:
|
||
|
||
// Helper: run the sequencer to completion collecting ordered events.
|
||
private static List<TeleportAnimEvent> RunToOff(
|
||
TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
|
||
{
|
||
var seq = new TeleportAnimSequencer();
|
||
var allEvts = new List<TeleportAnimEvent>();
|
||
seq.Begin(kind);
|
||
|
||
int safetyNet = 5000;
|
||
while (seq.IsActive && --safetyNet > 0)
|
||
{
|
||
// Simulate world becoming ready on first Tick inside Tunnel
|
||
bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel;
|
||
var (_, evts) = seq.Tick(step, worldReady: ready);
|
||
allEvts.AddRange(evts);
|
||
}
|
||
return allEvts;
|
||
}
|
||
|
||
[Fact]
|
||
public void Portal_FullSequence_EventsInOrder()
|
||
{
|
||
var evts = RunToOff(TeleportEntryKind.Portal);
|
||
|
||
// Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete.
|
||
// EnterTunnel is optional but must come before Place if present.
|
||
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
|
||
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
|
||
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
|
||
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
|
||
|
||
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
|
||
Assert.True(placeIdx >= 0, "Place must fire");
|
||
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
|
||
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
|
||
|
||
Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place");
|
||
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
|
||
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
|
||
}
|
||
|
||
[Fact]
|
||
public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades()
|
||
{
|
||
var evts = RunToOff(TeleportEntryKind.Logout);
|
||
|
||
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
|
||
int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel);
|
||
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
|
||
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
|
||
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
|
||
|
||
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
|
||
Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)");
|
||
Assert.True(placeIdx >= 0, "Place must fire");
|
||
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
|
||
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
|
||
|
||
Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel");
|
||
Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place");
|
||
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
|
||
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
|
||
}
|
||
|
||
[Fact]
|
||
public void Portal_NoEventsFiredTwice()
|
||
{
|
||
var evts = RunToOff(TeleportEntryKind.Portal);
|
||
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound));
|
||
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place));
|
||
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound));
|
||
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete));
|
||
}
|
||
|
||
[Fact]
|
||
public void Death_BehavesIdenticallyToPortal_FullSequence()
|
||
{
|
||
var portalEvts = RunToOff(TeleportEntryKind.Portal);
|
||
var deathEvts = RunToOff(TeleportEntryKind.Death);
|
||
// Same event sequence (both enter at Tunnel)
|
||
Assert.Equal(portalEvts, deathEvts);
|
||
}
|
||
|
||
[Fact]
|
||
public void Login_BehavesIdenticallyToPortal_FullSequence()
|
||
{
|
||
var portalEvts = RunToOff(TeleportEntryKind.Portal);
|
||
var loginEvts = RunToOff(TeleportEntryKind.Login);
|
||
Assert.Equal(portalEvts, loginEvts);
|
||
}
|
||
|
||
[Fact]
|
||
public void AfterOff_IsActiveIsFalse_AndStateIsOff()
|
||
{
|
||
RunToOff(TeleportEntryKind.Portal);
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
|
||
// Drain to Off
|
||
for (int i = 0; i < 5000 && seq.IsActive; i++)
|
||
{
|
||
bool ready = seq.State == TeleportAnimState.Tunnel;
|
||
seq.Tick(0.05f, worldReady: ready);
|
||
}
|
||
|
||
Assert.False(seq.IsActive);
|
||
Assert.Equal(TeleportAnimState.Off, seq.State);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_FullSequence_EventsInOrder"
|
||
```
|
||
Expected: FAIL — `EnterTunnel` event not fired correctly through Logout's TunnelFadeIn→Tunnel transition in current stub (Task 1.3's `_enterTunnelPending` wiring needs verification).
|
||
|
||
- [ ] **Step 3: Implement** — verify `_enterTunnelPending` is flushed at the START of the NEXT Tick, not deferred further. The current `Tick()` already has:
|
||
```csharp
|
||
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
|
||
```
|
||
which runs before the state-machine switch. The `Advance(TunnelFadeIn→Tunnel, enterTunnel: true)` sets `_enterTunnelPending = true`, which flushes on the next Tick. This is already correct. No code change needed — the `RunToOff` helper exercises the Logout path which was not hit before. If EnterTunnel fires on the same tick as the transition, it is counted in `allEvts` correctly because `DriveSeconds` accumulates all per-tick events.
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
```
|
||
Expected: PASS for all sequencer tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "test(core/slice-1): full portal+logout event-sequence ordering + no-duplicate-fire coverage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.6: EnterTunnel fired for Portal path via Logout-to-Tunnel transition correctness + Build green
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs` (if any gap surfaces)
|
||
- Run: `dotnet build` and full test suite
|
||
|
||
- [ ] **Step 1: Write the failing test** — assert EnterTunnel fires on the Portal path (Portal enters Tunnel directly; the Begin sets state to Tunnel but EnterTunnel should still fire since the tunnel is newly entered):
|
||
|
||
```csharp
|
||
// Add to TeleportAnimSequencerTests:
|
||
|
||
[Fact]
|
||
public void Portal_EmitsEnterTunnel_OnFirstTick()
|
||
{
|
||
// Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden".
|
||
var seq = new TeleportAnimSequencer();
|
||
seq.Begin(TeleportEntryKind.Portal);
|
||
|
||
// First tick: should emit PlayEnterSound AND EnterTunnel (both on entry)
|
||
var (_, evts) = seq.Tick(0f, worldReady: false);
|
||
Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts);
|
||
Assert.Contains(TeleportAnimEvent.EnterTunnel, evts);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_EmitsEnterTunnel_OnFirstTick"
|
||
```
|
||
Expected: FAIL — `Begin` with Portal kind doesn't set `_enterTunnelPending`.
|
||
|
||
- [ ] **Step 3: Implement** — update `Begin()` to also set `_enterTunnelPending` when entering at Tunnel:
|
||
|
||
```csharp
|
||
public void Begin(TeleportEntryKind kind)
|
||
{
|
||
_state = kind == TeleportEntryKind.Logout
|
||
? TeleportAnimState.WorldFadeOut
|
||
: TeleportAnimState.Tunnel;
|
||
_elapsed = 0f;
|
||
_continueElapsed = 0f;
|
||
_enterSoundPending = true;
|
||
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
|
||
_exitSoundPending = false;
|
||
_loginCompletePending = false;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run all sequencer tests + build**
|
||
```
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
|
||
dotnet build src/AcDream.Core/AcDream.Core.csproj
|
||
```
|
||
Expected: all PASS, build green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
|
||
git commit -m "feat(core/slice-1): Begin() sets EnterTunnel pending for portal/login/death entry; all sequencer tests pass"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.7: Full build + full test suite green; register divergence row
|
||
|
||
**Files:**
|
||
- Modify: `docs/architecture/retail-divergence-register.md` (add smoothstep row per spec §5 / §3.2)
|
||
|
||
- [ ] **Step 1:** No test to write — this is the completion gate.
|
||
|
||
- [ ] **Step 2:** Run full suite:
|
||
```
|
||
dotnet build
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
|
||
```
|
||
Expected: build green; all tests pass (including pre-existing suite).
|
||
|
||
- [ ] **Step 3: Implement** — add the divergence-register row. Read the existing register to find the insertion point:
|
||
Open `docs/architecture/retail-divergence-register.md`, locate the table body, and add one row:
|
||
|
||
```
|
||
| TAS-smoothstep | TeleportAnimSequencer.ComputeFadeAlpha | GetAnimLevel 1024-entry table approximated as smoothstep | approximation | src/AcDream.Core/World/TeleportAnimSequencer.cs | retire if GetAnimLevel table resolved via cdb read (spec §8) |
|
||
```
|
||
|
||
(Exact column order matches the existing register rows — read the file header before inserting.)
|
||
|
||
- [ ] **Step 4:** Build + test green (same commands as Step 2).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```
|
||
git add docs/architecture/retail-divergence-register.md
|
||
git commit -m "docs(slice-1): retail-divergence-register — TAS smoothstep approximation row (spec §5 row 2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Slice 2 Task List — Readiness Gate (#145 Residual / Work-Item-A Fix)
|
||
|
||
---
|
||
|
||
### Task 2.1: Add `PhysicsEngine.IsLandblockLoaded` with unit test
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (after `LandblockCount` property, line 32)
|
||
- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs` (new file)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
// tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class PhysicsEngineIsLandblockLoadedTests
|
||
{
|
||
private static TerrainSurface MakeFlatTerrain()
|
||
{
|
||
var heights = new byte[81];
|
||
Array.Fill(heights, (byte)50);
|
||
var heightTable = new float[256];
|
||
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
|
||
return new TerrainSurface(heights, heightTable);
|
||
}
|
||
|
||
[Fact]
|
||
public void IsLandblockLoaded_AbsentId_ReturnsFalse()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsLandblockLoaded_AddedId_ReturnsTrue()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
|
||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
|
||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsLandblockLoaded_AfterRemove_ReturnsFalse()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
|
||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
engine.RemoveLandblock(0xA9B4FFFFu);
|
||
|
||
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"`
|
||
|
||
Expected: FAIL — `CS1061: 'PhysicsEngine' does not contain a definition for 'IsLandblockLoaded'`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `src/AcDream.Core/Physics/PhysicsEngine.cs`, after `LandblockCount` (line 32):
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Returns true iff a landblock with this id has been registered via
|
||
/// <see cref="AddLandblock"/>. Because <see cref="AddLandblock"/> registers
|
||
/// terrain + collision cells + portals atomically, ContainsKey implies the
|
||
/// collision mesh is present and resolvable.
|
||
/// </summary>
|
||
public bool IsLandblockLoaded(uint landblockId) =>
|
||
_landblocks.ContainsKey(landblockId);
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"`
|
||
|
||
Expected: PASS (4 tests, all green).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```
|
||
git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs && git commit -m "feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.2: Extend `TeleportArrivalRules.Decide` with `outdoorReady` and update truth-table tests
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules.Decide`, line 119)
|
||
- Modify: `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs` (extend existing tests + add new ones)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Replace the content of `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs`:
|
||
|
||
```csharp
|
||
using AcDream.App.World;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.World;
|
||
|
||
/// <summary>
|
||
/// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change.
|
||
/// </summary>
|
||
public class TeleportArrivalRulesTests
|
||
{
|
||
[Fact]
|
||
public void Unhydratable_IsImpossible_Outdoor()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.Impossible,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: true, indoor: false,
|
||
indoorCellReady: false, outdoorReady: false));
|
||
}
|
||
|
||
[Fact]
|
||
public void Unhydratable_IsImpossible_Indoor()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.Impossible,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: true, indoor: true,
|
||
indoorCellReady: true, outdoorReady: false));
|
||
}
|
||
|
||
[Fact]
|
||
public void Indoor_CellReady_IsReady()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.Ready,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: true,
|
||
indoorCellReady: true, outdoorReady: false));
|
||
}
|
||
|
||
[Fact]
|
||
public void Indoor_CellNotReady_IsNotReady()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.NotReady,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: true,
|
||
indoorCellReady: false, outdoorReady: false));
|
||
}
|
||
|
||
[Fact]
|
||
public void Outdoor_LandblockLoaded_IsReady()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.Ready,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: false,
|
||
indoorCellReady: false, outdoorReady: true));
|
||
}
|
||
|
||
[Fact]
|
||
public void Outdoor_LandblockNotLoaded_IsNotReady()
|
||
{
|
||
// §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock
|
||
// must hold. Previously returned Ready immediately.
|
||
Assert.Equal(ArrivalReadiness.NotReady,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: false,
|
||
indoorCellReady: false, outdoorReady: false));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`
|
||
|
||
Expected: FAIL — `Outdoor_LandblockNotLoaded_IsNotReady` fails (current code returns `Ready` for all outdoor), and `Outdoor_LandblockLoaded_IsReady` fails with a compile error because the new parameter doesn't exist yet.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `src/AcDream.App/World/TeleportArrivalController.cs`, replace `TeleportArrivalRules.Decide` (lines 119–143):
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Decide whether a held teleport arrival can place now.
|
||
/// </summary>
|
||
/// <param name="claimUnhydratable">The destination cell can never hydrate.</param>
|
||
/// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param>
|
||
/// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param>
|
||
/// <param name="outdoorReady">For an outdoor destination, the physics landblock is loaded
|
||
/// (<see cref="PhysicsEngine.IsLandblockLoaded"/> returned true for the destination landblock id).
|
||
/// Ignored for indoor destinations.</param>
|
||
public static ArrivalReadiness Decide(
|
||
bool claimUnhydratable,
|
||
bool indoor,
|
||
bool indoorCellReady,
|
||
bool outdoorReady)
|
||
{
|
||
if (claimUnhydratable)
|
||
return ArrivalReadiness.Impossible;
|
||
|
||
// Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
|
||
// exactly as #135. Unaffected by the outdoor-landblock gate.
|
||
if (indoor)
|
||
return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
|
||
|
||
// Outdoor (#145 §3.4): hold until the physics landblock is registered.
|
||
// AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70),
|
||
// so ContainsKey implies the collision mesh is present and resolvable. The hold is
|
||
// self-resolving: streaming progresses unconditionally during the hold because the
|
||
// streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551).
|
||
// The 10 s timeout safety-net (_maxHoldFrames) remains active.
|
||
return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`
|
||
|
||
Expected: PASS (6 tests, all green).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```
|
||
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.3: Fix the compile break — update the one `Decide` call-site in `GameWindow.cs`
|
||
|
||
The signature change in Task 2.2 breaks the call at `GameWindow.cs:5528`. This task wires `outdoorReady` and verifies the build is green. There is no unit test for GameWindow wiring (god object); verification is build-green + a described manual run.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (method `TeleportArrivalReadiness`, around line 5513)
|
||
|
||
- [ ] **Step 1: Locate the anchor** — `GameWindow.cs:5528`:
|
||
|
||
```csharp
|
||
// existing line 5528:
|
||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||
claimUnhydratable, indoor, indoorCellReady);
|
||
```
|
||
|
||
- [ ] **Step 2: Apply the edit** — replace the three-argument call with a four-argument call:
|
||
|
||
```csharp
|
||
// After the existing:
|
||
// bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||
// bool indoorCellReady = indoor && !claimUnhydratable
|
||
// && _physicsEngine.IsSpawnCellReady(destCell);
|
||
// Add:
|
||
bool outdoorReady = !indoor
|
||
&& _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
|
||
|
||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||
claimUnhydratable, indoor, indoorCellReady, outdoorReady);
|
||
```
|
||
|
||
The full method body after the edit:
|
||
|
||
```csharp
|
||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||
System.Numerics.Vector3 destPos, uint destCell)
|
||
{
|
||
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
|
||
|
||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||
bool indoorCellReady = indoor && !claimUnhydratable
|
||
&& _physicsEngine.IsSpawnCellReady(destCell);
|
||
bool outdoorReady = !indoor
|
||
&& _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
|
||
|
||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||
claimUnhydratable, indoor, indoorCellReady, outdoorReady);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build green**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```
|
||
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): wire outdoorReady into TeleportArrivalReadiness (GameWindow)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.4: Add `ProbeArrivalGate` flag to `PhysicsDiagnostics` and log the `NotReady → Ready` transition
|
||
|
||
The existing `PhysicsDiagnostics` pattern (read `PhysicsDiagnostics.cs:31–32` for the template): a `static bool` property seeded from an env var, listed in `ResetForTest()`. The probe log lives in `TeleportArrivalReadiness` in `GameWindow.cs`.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (add property + `ResetForTest` entry)
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add `_lastArrivalVerdict` field + probe emission in `TeleportArrivalReadiness`)
|
||
|
||
There is no unit test for a diagnostic emission; verification is the described manual run in Task 2.5.
|
||
|
||
- [ ] **Step 1: Add the probe flag to `PhysicsDiagnostics`**
|
||
|
||
In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, after `ProbeResolveEnabled` (after line 32):
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of
|
||
/// the <c>TeleportArrivalReadiness</c> verdict (NotReady → Ready / Impossible) emits one
|
||
/// <c>[arrival-gate]</c> line: the destination cell id, landblock id, verdict,
|
||
/// <c>PhysicsEngine.LandblockCount</c> at the moment it flips, and whether the
|
||
/// flip was forced (Impossible) or natural (Ready).
|
||
///
|
||
/// <para>Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c> — shares the existing probe
|
||
/// flag because this is a readiness-path extension; no new env var needed.</para>
|
||
/// </summary>
|
||
public static bool ProbeArrivalGateEnabled { get; set; } =
|
||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
|
||
```
|
||
|
||
In `PhysicsDiagnostics.ResetForTest()` (line 536 area), add after `ProbeResolveEnabled = false;`:
|
||
|
||
```csharp
|
||
ProbeArrivalGateEnabled = false;
|
||
```
|
||
|
||
- [ ] **Step 2: Add the transition-tracking field and probe emission to GameWindow**
|
||
|
||
In `src/AcDream.App/Rendering/GameWindow.cs`, near the `TeleportArrivalController` field (grep for `_teleportArrival` to find the declaration block), add:
|
||
|
||
```csharp
|
||
private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;
|
||
```
|
||
|
||
Then in `TeleportArrivalReadiness` (after computing the verdict and before returning), add the probe emission:
|
||
|
||
```csharp
|
||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||
System.Numerics.Vector3 destPos, uint destCell)
|
||
{
|
||
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
|
||
|
||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||
bool indoorCellReady = indoor && !claimUnhydratable
|
||
&& _physicsEngine.IsSpawnCellReady(destCell);
|
||
bool outdoorReady = !indoor
|
||
&& _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
|
||
|
||
var verdict = AcDream.App.World.TeleportArrivalRules.Decide(
|
||
claimUnhydratable, indoor, indoorCellReady, outdoorReady);
|
||
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled
|
||
&& verdict != _lastArrivalVerdict
|
||
&& verdict != AcDream.App.World.ArrivalReadiness.NotReady)
|
||
{
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[arrival-gate] destCell=0x{destCell:X8} " +
|
||
$"landblock=0x{(destCell & 0xFFFF0000u):X8} " +
|
||
$"verdict={verdict} indoor={indoor} " +
|
||
$"landblockCount={_physicsEngine.LandblockCount}"));
|
||
}
|
||
_lastArrivalVerdict = verdict;
|
||
|
||
return verdict;
|
||
}
|
||
```
|
||
|
||
Reset `_lastArrivalVerdict` to `NotReady` in `BeginArrival` (i.e. wherever `_teleportArrival.BeginArrival` is called) so each new hold starts fresh. Find the `BeginArrival` call site by searching for `_teleportArrival.BeginArrival` in `GameWindow.cs`; immediately before or after that call add:
|
||
|
||
```csharp
|
||
_lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;
|
||
```
|
||
|
||
- [ ] **Step 3: Build green**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
|
||
Expected: zero errors, zero warnings on the touched files.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```
|
||
git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): arrival-gate probe — log NotReady→Ready flip with landblock count"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.5: Apparatus re-test — verify the gate on the `0xC98C` edge-arrival scenario
|
||
|
||
This task has no automated xunit test — it is a described manual run against a live session using the probe.
|
||
|
||
**What the test proves:** The readiness gate correctly holds an outdoor arrival until `AddLandblock` fires for the destination, then flips `Ready` in the same streaming frame — no cell-march, no Z free-fall.
|
||
|
||
- [ ] **Step 1: Launch with the probe enabled**
|
||
|
||
In PowerShell:
|
||
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||
$env:ACDREAM_LIVE = "1"
|
||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||
$env:ACDREAM_TEST_PORT = "9000"
|
||
$env:ACDREAM_TEST_USER = "testaccount"
|
||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||
$env:ACDREAM_PROBE_RESOLVE = "1" # gates both ProbeResolveEnabled and ProbeArrivalGateEnabled
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-gate-retest.log"
|
||
```
|
||
|
||
- [ ] **Step 2: Reproduce the `0xC98C` scenario**
|
||
|
||
Use the `/teleport` GM command to portal to a landblock near a streaming edge — ideally the same destination (`0xC98C…`) documented in `launch5.log`. Alternatively, use `/tp 0xC98C0200` or portal via a gate that forces a cross-landblock hop.
|
||
|
||
- [ ] **Step 3: Inspect the log**
|
||
|
||
Look for `[arrival-gate]` lines in `launch-gate-retest.log`. The expected pattern:
|
||
|
||
```
|
||
[arrival-gate] destCell=0xC98C0200 landblock=0xC98C0000 verdict=Ready indoor=False landblockCount=N
|
||
live: teleport complete — snapped to (...) cell=0xC98C...
|
||
```
|
||
|
||
The `[arrival-gate]` line MUST appear before the `live: teleport complete` line. `landblockCount` should be ≥ 1 at the moment the gate flips.
|
||
|
||
The probe MUST NOT be followed by any sequence of `[resolve]` lines that show `branch=NO-LANDBLOCK` after the placement — that is the Z-free-fall symptom. If NO-LANDBLOCK lines appear after `live: teleport complete`, the fix has a gap and must be investigated before closing.
|
||
|
||
- [ ] **Step 4: Confirm no regression on indoor arrival**
|
||
|
||
Enter a dungeon (walk into a portal in Holtburg). The log should show `[arrival-gate] indoor=True` lines while `NotReady`, then a flip to `Ready` once the EnvCell floor hydrates — same behavior as before. The change must not regress the indoor path.
|
||
|
||
- [ ] **Step 5: Commit result note**
|
||
|
||
If the run passes, record a one-line note in the commit that closes this apparatus task:
|
||
|
||
```
|
||
git commit --allow-empty -m "test(slice2): apparatus re-test passed — arrival gate flips Ready before placement on 0xC98C edge arrival; no NO-LANDBLOCK march observed"
|
||
```
|
||
|
||
If the run reveals a gap, file it as a new issue before continuing.
|
||
|
||
---
|
||
|
||
### Task 2.6: Full test suite green + run-all confirmation
|
||
|
||
- [ ] **Step 1: Run the full suite**
|
||
|
||
```
|
||
dotnet test AcDream.slnx -c Debug
|
||
```
|
||
|
||
Expected: all tests pass. No new test failures introduced by the `Decide` signature change. (The only existing caller was `TeleportArrivalRulesTests.cs`, which was updated in Task 2.2. `TeleportArrivalControllerTests.cs` exercises the controller via the delegate — not `Decide` directly — so it is unaffected.)
|
||
|
||
- [ ] **Step 2: Confirm `TeleportArrivalControllerTests` still passes**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
|
||
|
||
Expected: PASS (the controller's injected `_readiness` delegate passes `ArrivalReadiness` values directly; it does not call `Decide` internally, so the signature change is invisible to it).
|
||
|
||
- [ ] **Step 3: Commit (if not already committed per-task above)**
|
||
|
||
No additional commit if each task was committed individually. Otherwise:
|
||
|
||
```
|
||
git add -u && git commit -m "feat(slice2): readiness gate complete — IsLandblockLoaded + Decide(outdoorReady) + probe (#145 §3.4)"
|
||
```
|
||
|
||
---
|
||
|
||
### Slice 3: TeleportFlowController + TeleportFadeOverlay (portal through the sequencer + fade)
|
||
|
||
#### Task 3.1 — `TeleportFlowController` (delegate-injected, unit-tested)
|
||
|
||
**Files:**
|
||
- Create `src/AcDream.App/World/TeleportFlowController.cs` (namespace `AcDream.App.World`).
|
||
- Test: `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (match style of `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs:1-147` — `record`-based call spies, static `Make` helper, `[Fact]`).
|
||
- Consumes (do NOT redefine): `AcDream.Core.World.TeleportAnimSequencer` / `TeleportAnimState` / `TeleportEntryKind` / `TeleportAnimEvent` / `TeleportAnimSnapshot` (slice 1); `AcDream.App.World.ArrivalReadiness` (`src/AcDream.App/World/TeleportArrivalController.cs:7-19`).
|
||
|
||
- [ ] **Step 1: Write the failing test.** Real xunit, mirroring `TeleportArrivalControllerTests` spy style:
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.World;
|
||
using AcDream.Core.World;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.World;
|
||
|
||
public class TeleportFlowControllerTests
|
||
{
|
||
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
|
||
|
||
private sealed class Spies
|
||
{
|
||
public readonly List<PlaceCall> Placed = new();
|
||
public int LoginComplete;
|
||
public int LogoutDisconnect;
|
||
public int EnterSound;
|
||
public int ExitSound;
|
||
}
|
||
|
||
// readinessQueue: one verdict per Tick (last value repeats once exhausted).
|
||
private static TeleportFlowController Make(
|
||
Spies s, Queue<ArrivalReadiness> readinessQueue,
|
||
int maxHoldFrames = TeleportFlowController.DefaultMaxHoldFrames)
|
||
{
|
||
ArrivalReadiness last = ArrivalReadiness.NotReady;
|
||
return new TeleportFlowController(
|
||
readiness: (_, _) => { if (readinessQueue.Count > 0) last = readinessQueue.Dequeue(); return last; },
|
||
onPlace: (pos, cell, forced) => s.Placed.Add(new PlaceCall(pos, cell, forced)),
|
||
onLoginComplete: () => s.LoginComplete++,
|
||
onLogoutDisconnect: () => s.LogoutDisconnect++,
|
||
onPlayEnterSound: () => s.EnterSound++,
|
||
onPlayExitSound: () => s.ExitSound++,
|
||
maxHoldFrames: maxHoldFrames);
|
||
}
|
||
|
||
// Drive enough fixed-dt ticks (1/60s) to walk a portal run all the way to OFF.
|
||
private static void RunToOff(TeleportFlowController c, int maxTicks = 2000)
|
||
{
|
||
for (int i = 0; i < maxTicks && c.IsActive; i++) c.Tick(1f / 60f);
|
||
}
|
||
|
||
[Fact]
|
||
public void Tick_BeforeBegin_IsNoOp()
|
||
{
|
||
var s = new Spies();
|
||
var c = Make(s, new Queue<ArrivalReadiness>());
|
||
c.Tick(1f / 60f);
|
||
Assert.False(c.IsActive);
|
||
Assert.Empty(s.Placed);
|
||
}
|
||
|
||
[Fact]
|
||
public void Portal_FullRun_FiresEnterPlaceExitLoginComplete_InOrder()
|
||
{
|
||
var s = new Spies();
|
||
// readiness Ready from the first probe so the tunnel exits as soon as it can hold.
|
||
var q = new Queue<ArrivalReadiness>();
|
||
q.Enqueue(ArrivalReadiness.Ready);
|
||
var c = Make(s, q);
|
||
|
||
c.Begin(TeleportEntryKind.Portal, new Vector3(10, 20, 6), 0x01250126u);
|
||
RunToOff(c);
|
||
|
||
Assert.False(c.IsActive);
|
||
Assert.Equal(1, s.EnterSound);
|
||
var place = Assert.Single(s.Placed);
|
||
Assert.False(place.Forced);
|
||
Assert.Equal(0x01250126u, place.Cell);
|
||
Assert.Equal(new Vector3(10, 20, 6), place.Pos);
|
||
Assert.Equal(1, s.ExitSound);
|
||
Assert.Equal(1, s.LoginComplete);
|
||
Assert.Equal(0, s.LogoutDisconnect);
|
||
}
|
||
|
||
[Fact]
|
||
public void Begin_Idempotent_SecondBeginWithCoords_DoesNotRestartSequencer()
|
||
{
|
||
var s = new Spies();
|
||
var q = new Queue<ArrivalReadiness>();
|
||
q.Enqueue(ArrivalReadiness.NotReady); // hold in TUNNEL after first probe
|
||
var c = Make(s, q);
|
||
|
||
c.Begin(TeleportEntryKind.Portal); // no coords (OnTeleportStarted)
|
||
var stateAfterFirst = c.Snapshot.State; // sequencer began (Tunnel)
|
||
c.Tick(1f / 60f); // holding; no coords -> worldReady false for Portal
|
||
c.Begin(TeleportEntryKind.Portal, new Vector3(1, 2, 3), 0x01250126u); // coords arrive
|
||
|
||
// Still active, still no premature place: the second Begin must NOT re-call _seq.Begin.
|
||
Assert.True(c.IsActive);
|
||
Assert.Empty(s.Placed);
|
||
Assert.Equal(TeleportAnimState.Tunnel, c.Snapshot.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete()
|
||
{
|
||
var s = new Spies();
|
||
var c = Make(s, new Queue<ArrivalReadiness>()); // no dest -> Logout worldReady=true intrinsically
|
||
c.Begin(TeleportEntryKind.Logout);
|
||
RunToOff(c);
|
||
|
||
Assert.False(c.IsActive);
|
||
Assert.Equal(1, s.LogoutDisconnect);
|
||
Assert.Equal(0, s.LoginComplete);
|
||
Assert.Empty(s.Placed); // _haveDest false -> Place event is a no-op
|
||
Assert.Equal(1, s.ExitSound);
|
||
}
|
||
|
||
[Fact]
|
||
public void Impossible_ForcesPlace_WithForcedTrue()
|
||
{
|
||
var s = new Spies();
|
||
var q = new Queue<ArrivalReadiness>();
|
||
q.Enqueue(ArrivalReadiness.Impossible);
|
||
var c = Make(s, q);
|
||
c.Begin(TeleportEntryKind.Portal, new Vector3(4, 5, 6), 0x0125FF00u);
|
||
RunToOff(c);
|
||
var place = Assert.Single(s.Placed);
|
||
Assert.True(place.Forced);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests"`. Expected: FAIL — `TeleportFlowController` does not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement** the pinned class verbatim:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.World;
|
||
|
||
/// <summary>
|
||
/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps
|
||
/// the pure <see cref="TeleportAnimSequencer"/> (the 7-state TAS machine) around the
|
||
/// existing arrival-hold readiness probe. Single owner of "a teleport is happening".
|
||
///
|
||
/// <para>All side effects are injected delegates (same pure-seam style as
|
||
/// <see cref="TeleportArrivalController"/>): readiness probe, placement, login-complete,
|
||
/// logout teardown, and the two portal sounds. Carries no GL / dat / net dependency
|
||
/// directly, so it is fully unit-testable.</para>
|
||
///
|
||
/// <para>Drives: the sequencer per frame with a <c>worldReady</c> gate computed from the
|
||
/// readiness probe; on the sequencer's <see cref="TeleportAnimEvent.Place"/> it runs the
|
||
/// placement (Resolve + SetPosition + camera); at <see cref="TeleportAnimEvent.FireLoginComplete"/>
|
||
/// (sequencer reached <see cref="TeleportAnimState.Off"/>) it either disconnects (Logout)
|
||
/// or sends LoginComplete + flips PortalSpace->InWorld (all other kinds). The input lock
|
||
/// therefore persists the WHOLE animation, not just until placement.</para>
|
||
/// </summary>
|
||
public sealed class TeleportFlowController
|
||
{
|
||
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
|
||
public const int DefaultMaxHoldFrames = 600;
|
||
|
||
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
|
||
private readonly Action<Vector3, uint, bool> _onPlace; // (destPos, destCell, forced)
|
||
private readonly Action _onLoginComplete;
|
||
private readonly Action _onLogoutDisconnect;
|
||
private readonly Action _onPlayEnterSound;
|
||
private readonly Action _onPlayExitSound;
|
||
private readonly int _maxHoldFrames;
|
||
|
||
private readonly TeleportAnimSequencer _seq = new();
|
||
|
||
private TeleportEntryKind _kind;
|
||
private Vector3 _destPos;
|
||
private uint _destCell;
|
||
private bool _haveDest;
|
||
private int _heldFrames;
|
||
private bool _forced;
|
||
|
||
public TeleportFlowController(
|
||
Func<Vector3, uint, ArrivalReadiness> readiness,
|
||
Action<Vector3, uint, bool> onPlace,
|
||
Action onLoginComplete,
|
||
Action onLogoutDisconnect,
|
||
Action onPlayEnterSound,
|
||
Action onPlayExitSound,
|
||
int maxHoldFrames = DefaultMaxHoldFrames)
|
||
{
|
||
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
|
||
_onPlace = onPlace ?? throw new ArgumentNullException(nameof(onPlace));
|
||
_onLoginComplete = onLoginComplete ?? throw new ArgumentNullException(nameof(onLoginComplete));
|
||
_onLogoutDisconnect = onLogoutDisconnect ?? throw new ArgumentNullException(nameof(onLogoutDisconnect));
|
||
_onPlayEnterSound = onPlayEnterSound ?? throw new ArgumentNullException(nameof(onPlayEnterSound));
|
||
_onPlayExitSound = onPlayExitSound ?? throw new ArgumentNullException(nameof(onPlayExitSound));
|
||
_maxHoldFrames = maxHoldFrames;
|
||
}
|
||
|
||
/// <summary>Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags.</summary>
|
||
public TeleportAnimSnapshot Snapshot { get; private set; }
|
||
|
||
public bool IsActive => _seq.IsActive;
|
||
|
||
/// <summary>Start (or supply coords to) a teleport flow. Idempotent on the sequencer:
|
||
/// the first call (OnTeleportStarted, no coords) begins it; a later call with coords
|
||
/// (OnLivePositionUpdated) fills the destination WITHOUT restarting the animation.</summary>
|
||
public void Begin(TeleportEntryKind kind, Vector3 destPos = default, uint destCell = 0)
|
||
{
|
||
_kind = kind;
|
||
if (destCell != 0)
|
||
{
|
||
_destPos = destPos;
|
||
_destCell = destCell;
|
||
_haveDest = true;
|
||
_heldFrames = 0;
|
||
}
|
||
if (!_seq.IsActive)
|
||
_seq.Begin(kind);
|
||
}
|
||
|
||
public void Tick(float dt)
|
||
{
|
||
if (!_seq.IsActive) return;
|
||
|
||
bool worldReady;
|
||
if (_haveDest)
|
||
{
|
||
ArrivalReadiness v = _readiness(_destPos, _destCell);
|
||
_heldFrames++;
|
||
bool forced = v == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames;
|
||
worldReady = v == ArrivalReadiness.Ready || forced;
|
||
_forced = forced && v != ArrivalReadiness.Ready;
|
||
}
|
||
else
|
||
{
|
||
// Logout has no destination world to load; Portal/Death/Login wait for coords.
|
||
worldReady = _kind == TeleportEntryKind.Logout;
|
||
}
|
||
|
||
var (snap, events) = _seq.Tick(dt, worldReady);
|
||
Snapshot = snap;
|
||
|
||
foreach (var e in events)
|
||
{
|
||
switch (e)
|
||
{
|
||
case TeleportAnimEvent.PlayEnterSound:
|
||
_onPlayEnterSound();
|
||
break;
|
||
case TeleportAnimEvent.PlayExitSound:
|
||
_onPlayExitSound();
|
||
break;
|
||
case TeleportAnimEvent.Place:
|
||
if (_haveDest) _onPlace(_destPos, _destCell, _forced);
|
||
break;
|
||
case TeleportAnimEvent.FireLoginComplete:
|
||
if (_kind == TeleportEntryKind.Logout) _onLogoutDisconnect();
|
||
else _onLoginComplete();
|
||
break;
|
||
case TeleportAnimEvent.EnterTunnel:
|
||
// no-op — the sequencer drives the tunnel visual state itself.
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS.
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/World/TeleportFlowController.cs tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs && git commit -m "feat(teleport): Slice 3 — TeleportFlowController wrapping the TAS sequencer with delegate side-effects"`
|
||
|
||
---
|
||
|
||
#### Task 3.2 — `TeleportFadeOverlay` (full-screen alpha-black UiElement)
|
||
|
||
**Files:**
|
||
- Create `src/AcDream.App/UI/TeleportFadeOverlay.cs` (namespace `AcDream.App.UI`).
|
||
- Test: `tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs` (match a UiElement test, e.g. `tests/AcDream.App.Tests/UI/UiMeterTests.cs` style — these tests assert geometry/state without a live GL context).
|
||
- Depends on: `UiElement` (`src/AcDream.App/UI/UiElement.cs:32`, virtual `OnDraw(UiRenderContext)` at :168, `Visible` at :70, `Width`/`Height` at :50-51); `UiRenderContext.DrawRect(float,float,float,float,Vector4)` (`src/AcDream.App/UI/UiRenderContext.cs:78`); `UiRenderContext.ScreenSize` (:19); `UiElement.ClickThrough` (:78); `UiElement.ZOrder` (:94).
|
||
|
||
The overlay holds a settable `Alpha` (0..1). When `Alpha <= 0` it sets `Visible = false` (so it never participates in hit-test or draw). Because the overlay has no public render-surface seam that's GL-free, the *testable logic* is "Alpha→Visible gating + the draw color it would emit". Extract that into a pure method `FillColor()` so the test asserts it without a `UiRenderContext`.
|
||
|
||
- [ ] **Step 1: Write the failing test:**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.App.UI;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
public class TeleportFadeOverlayTests
|
||
{
|
||
[Fact]
|
||
public void Alpha_Zero_IsInvisible()
|
||
{
|
||
var o = new TeleportFadeOverlay { Alpha = 0f };
|
||
Assert.False(o.Visible);
|
||
}
|
||
|
||
[Fact]
|
||
public void Alpha_Positive_IsVisible_AndBlackWithThatAlpha()
|
||
{
|
||
var o = new TeleportFadeOverlay { Alpha = 0.5f };
|
||
Assert.True(o.Visible);
|
||
Assert.Equal(new Vector4(0f, 0f, 0f, 0.5f), o.FillColor());
|
||
}
|
||
|
||
[Fact]
|
||
public void Alpha_Clamps_To_Unit_Range()
|
||
{
|
||
var o = new TeleportFadeOverlay { Alpha = 5f };
|
||
Assert.Equal(1f, o.FillColor().W);
|
||
o.Alpha = -3f;
|
||
Assert.False(o.Visible);
|
||
Assert.Equal(0f, o.FillColor().W);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFadeOverlayTests"`. Expected: FAIL — `TeleportFadeOverlay` does not exist.
|
||
|
||
- [ ] **Step 3: Implement:**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Phase B (retail teleport flow): a top-most <see cref="UiElement"/> that fills the
|
||
/// whole screen with <c>(0,0,0,Alpha)</c>. The <see cref="TeleportFlowController"/> drives
|
||
/// <see cref="Alpha"/> from <c>Snapshot.FadeAlpha</c> each frame (0 = clear world, 1 = full
|
||
/// black). Added once to the <see cref="UiRoot"/> (highest ZOrder so it sits over panels).
|
||
///
|
||
/// <para>Divergence-register row: black fade via an alpha overlay quad, NOT retail's
|
||
/// camera near-plane collapse (Render::set_vdst to 0.001). Visually identical, no
|
||
/// projection surgery — see the design doc §3.3.</para>
|
||
/// </summary>
|
||
public sealed class TeleportFadeOverlay : UiElement
|
||
{
|
||
private float _alpha;
|
||
|
||
/// <summary>0..1 black-fade alpha. Setting it <= 0 hides the overlay entirely so it
|
||
/// never draws and never participates in hit-testing.</summary>
|
||
public float Alpha
|
||
{
|
||
get => _alpha;
|
||
set
|
||
{
|
||
_alpha = Math.Clamp(value, 0f, 1f);
|
||
Visible = _alpha > 0f;
|
||
}
|
||
}
|
||
|
||
public TeleportFadeOverlay()
|
||
{
|
||
// Decoration: never claim the pointer (the world / panels stay interactive when clear,
|
||
// and during a full-black hold the input lock is enforced separately by PortalSpace).
|
||
ClickThrough = true;
|
||
Visible = false;
|
||
ZOrder = int.MaxValue; // over every retail-UI panel
|
||
}
|
||
|
||
/// <summary>The fill color this overlay emits — pure black at the current alpha.
|
||
/// Pure (no GL) so it can be asserted in a unit test.</summary>
|
||
public Vector4 FillColor() => new(0f, 0f, 0f, _alpha);
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
if (_alpha <= 0f) return;
|
||
// Cover the entire screen regardless of this element's Left/Top (Root sits at origin,
|
||
// so local == screen). DrawRect composites after sprites — the fade must cover them.
|
||
var size = ctx.ScreenSize;
|
||
ctx.DrawRect(0f, 0f, size.X, size.Y, FillColor());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS.
|
||
- [ ] **Step: Add divergence-register row TF-FADE** — the alpha-overlay deviation is introduced here, so its row lands in this commit (CLAUDE.md register rule). In `docs/architecture/retail-divergence-register.md`, matching the existing column order, add (design §5 row 1):
|
||
|
||
```
|
||
| TF-FADE | Adaptation | TeleportFadeOverlay fills the screen with a black alpha quad instead of retail's camera near-plane collapse (Render::set_vdst -> 0.001, TAS §2.2). Visually identical. | src/AcDream.App/UI/TeleportFadeOverlay.cs | Edge-pixel depth-precision differences only; tolerable. |
|
||
```
|
||
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/UI/TeleportFadeOverlay.cs tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs docs/architecture/retail-divergence-register.md && git commit -m "feat(teleport): Slice 3 — TeleportFadeOverlay full-screen alpha-black fade quad + TF-FADE register row"`
|
||
|
||
---
|
||
|
||
#### Task 3.3 — GameWindow: wire the flow controller + overlay for the PORTAL path (integration, build-green + manual)
|
||
|
||
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (god object — no unit test; per the structure rules new LOGIC lives in 3.1/3.2, this task is wiring only).
|
||
|
||
Anchors (all line numbers from the current tree):
|
||
- `OnTeleportStarted` at `:5579`.
|
||
- `OnLivePositionUpdated` portal-space branch / `BeginArrival` call at `:5472-5474`.
|
||
- `PlaceTeleportArrival` body at `:5534-5570` (the SPLIT target).
|
||
- `EnsureTeleportArrivalController` at `:5501`.
|
||
- Per-frame `_teleportArrival?.Tick();` at `:7555`.
|
||
- `_uiHost.Draw(...)` at `:8994`.
|
||
- `_audioEngine`/`_soundCache`/`_dats` fields at `:250-251`/`:28`.
|
||
- `GameActionLoginComplete.Build()` (`src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs`).
|
||
|
||
- [ ] **Step 1 (edit): Add fields + a lazy ensure** near `_teleportArrival` (`:5482`):
|
||
|
||
```csharp
|
||
// Phase B (retail teleport flow): the TAS state machine + its full-screen fade overlay.
|
||
// Replaces the bare _teleportArrival hold for the player. Lazily constructed on the
|
||
// first teleport (all runtime deps wired by then), same as _teleportArrival.
|
||
private AcDream.App.World.TeleportFlowController? _teleportFlow;
|
||
private AcDream.App.UI.TeleportFadeOverlay? _teleportFade;
|
||
|
||
private void EnsureTeleportFlowController()
|
||
{
|
||
if (_teleportFlow is not null) return;
|
||
_teleportFlow = new AcDream.App.World.TeleportFlowController(
|
||
readiness: TeleportArrivalReadiness,
|
||
onPlace: PlaceTeleportArrivalCore,
|
||
onLoginComplete: OnTeleportLoginComplete,
|
||
onLogoutDisconnect: () => { _liveSessionController?.Dispose(); _liveSessionController = null; },
|
||
onPlayEnterSound: () => PlayPortalSound(PortalSoundKind.Enter), // Slice 4 supplies PlayPortalSound
|
||
onPlayExitSound: () => PlayPortalSound(PortalSoundKind.Exit));
|
||
_teleportFade = new AcDream.App.UI.TeleportFadeOverlay();
|
||
_uiHost?.Root.AddChild(_teleportFade);
|
||
}
|
||
```
|
||
|
||
(Slice 4 adds `PlayPortalSound` + `PortalSoundKind`. For Slice 3 to build green on its own, temporarily pass `onPlayEnterSound: () => { }` / `onPlayExitSound: () => { }` and replace them in Task 4.2 — note this in the commit message.)
|
||
|
||
- [ ] **Step 2 (edit): SPLIT `PlaceTeleportArrival`** (`:5534-5570`). Rename the existing body to `PlaceTeleportArrivalCore` and REMOVE its last two statements (`_playerController.State = InWorld;` at `:5563` and the `_liveSession?.SendGameAction(...LoginComplete...)` at `:5568-5569`). Add the new login-complete delegate that owns exactly those two lines:
|
||
|
||
```csharp
|
||
// The placement half (Resolve + SetPosition + camera). Runs on the sequencer's Place
|
||
// event. Does NOT flip PortalSpace->InWorld and does NOT send LoginComplete — those move
|
||
// to OnTeleportLoginComplete (fired at sequencer OFF) so the input lock persists the
|
||
// WHOLE animation, not just until placement.
|
||
private void PlaceTeleportArrivalCore(
|
||
System.Numerics.Vector3 destPos, uint destCell, bool forced)
|
||
{
|
||
// ... existing body verbatim through the camera Update calls (:5537-5561) ...
|
||
if (forced)
|
||
Console.WriteLine($"live: teleport HOLD gave up (impossible/timeout) — force-snapping ...");
|
||
// (entity SetPosition + ParentCellId, _playerController.SetPosition, chase-camera Update)
|
||
Console.WriteLine($"live: teleport placed — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||
// NOTE: no State=InWorld, no LoginComplete here anymore.
|
||
}
|
||
|
||
// Fired at sequencer OFF (TeleportAnimEvent.FireLoginComplete) for every non-logout kind.
|
||
// Flips the input lock back on AND tells the server the client finished loading.
|
||
private void OnTeleportLoginComplete()
|
||
{
|
||
if (_playerController is not null)
|
||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||
_liveSession?.SendGameAction(
|
||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 (edit): Portal trigger.** In `OnTeleportStarted` (`:5583`), after `EnsureTeleportArrivalController();` add:
|
||
|
||
```csharp
|
||
EnsureTeleportFlowController();
|
||
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal); // no coords yet
|
||
```
|
||
|
||
In `OnLivePositionUpdated` portal-space branch, REPLACE the `_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);` at `:5474` with (keeping the recenter / pre-collapse above it untouched):
|
||
|
||
```csharp
|
||
EnsureTeleportFlowController();
|
||
_pendingTeleportRot = rot;
|
||
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal, newWorldPos, p.LandblockId);
|
||
```
|
||
|
||
- [ ] **Step 4 (edit): per-frame tick + overlay drive.** REPLACE `_teleportArrival?.Tick();` at `:7555` with:
|
||
|
||
```csharp
|
||
// Phase B: advance the teleport flow (TAS animation + arrival hold). Runs AFTER streaming
|
||
// + the live-session drain, same ordering invariant the old _teleportArrival.Tick had.
|
||
_teleportFlow?.Tick(dt);
|
||
if (_teleportFade is not null && _teleportFlow is not null)
|
||
_teleportFade.Alpha = _teleportFlow.Snapshot.FadeAlpha;
|
||
```
|
||
|
||
(`dt` is in scope at this call site — confirm by reading the enclosing method; if the local is named differently, use that name. Add a "read GameWindow.cs:7551 enclosing-method dt local to confirm name" check.) The fade overlay draws inside the existing `_uiHost.Draw(...)` pass at `:8994` because it was added as a `UiRoot` child in `EnsureTeleportFlowController` — no new draw site.
|
||
|
||
- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||
- [ ] **Step: MANUAL/VISUAL verification.** Launch against ACE (CLAUDE.md launch block), walk the player into a portal. Expect: screen fades to black, holds while the destination streams, fades back in; the camera no longer "floats" to the destination through an unstreamed world; input stays frozen until the fade-in completes (try pressing W during the black hold — no movement until the world is back). The tunnel states currently render as a black hold (the swirl is Slice 6).
|
||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(teleport): Slice 3 — route portal teleports through TeleportFlowController + fade overlay; split placement from login-complete"`
|
||
|
||
> Note: `_teleportArrival` becomes dead once the portal path routes through `_teleportFlow`. Leave it in place for Slice 3 (login/death/logout still need the unified path in Slice 5); Slice 5's de-dup commit removes the now-unused `_teleportArrival` field + `EnsureTeleportArrivalController` + `BeginArrival` plumbing.
|
||
|
||
---
|
||
|
||
### Slice 4: Yaw-freeze in PortalSpace + portal sounds
|
||
|
||
#### Task 4.1 — Yaw freeze during PortalSpace (build-green + manual; NO fabricated-signature unit test)
|
||
|
||
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` — the chase-camera mouse-move block.
|
||
|
||
Anchor: the single `if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)` block at `:1095`, which contains BOTH the MMB mouse-look sub-branch (`:1098`) and the RMB orbit sub-branch (`:1120`). One early-return at the TOP of that block covers both (per the prompt — do NOT add it in slice 3 or 5).
|
||
|
||
- [ ] **Step 1 (edit):** Insert as the first statement inside the `:1095` block, before `float sens = _sensChase;` (`:1097`):
|
||
|
||
```csharp
|
||
// Phase B (retail teleport flow): freeze look/orbit yaw while the teleport animation
|
||
// holds the player in PortalSpace. The keyboard-turn freeze already happens (the
|
||
// PlayerMovementController.Update early-return precedes the yaw block), but the Silk
|
||
// mouse-move handler bypasses Update — so MMB mouse-look (below) and RMB orbit both
|
||
// leak yaw without this gate. Covers BOTH sub-branches since they share this block.
|
||
if (_playerController is not null
|
||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||
{
|
||
_lastMouseX = pos.X;
|
||
_lastMouseY = pos.Y;
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||
- [ ] **Step: MANUAL/VISUAL verification.** During a portal (while the fade holds), hold MMB and move the mouse, then hold RMB and move the mouse. Expect: neither rotates the character/camera while in PortalSpace; both resume normally after the fade-in completes (State back to InWorld). (Mouse input can't be unit-tested through Silk — this is build-green + manual per the prompt; do NOT fabricate a `PlayerMovementController.Update` signature test.)
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "fix(teleport): Slice 4 — freeze MMB look + RMB orbit yaw while held in PortalSpace"`
|
||
|
||
---
|
||
|
||
#### Task 4.2 — Portal Enter/Exit sounds (clean EnumIDMap chain, no stopgap)
|
||
|
||
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (`PlayPortalSound` + `PortalSoundKind`).
|
||
|
||
> **VERIFIED CORRECTION — enum member names.** The design doc / handoff says `DRWSound.UI_EnterPortal=0x6A` / `UI_ExitPortal=0x6B`. That is **wrong** for our dat library. The real members are **`DRWSound.EnterPortal` and `DRWSound.ExitPortal`** (no `UI_` prefix). Therefore:
|
||
> - Do **NOT** add `UI_EnterPortal=0x6A`/`UI_ExitPortal=0x6B` to the Core `SoundId` enum (`src/AcDream.Core/Audio/AudioModel.cs:16-42`). The portal-sound chain uses `DatReaderWriter.Enums.Sound` (aliased `DRWSound`), NOT the Core `SoundId` — exactly as `SoundCookbook.Roll(SoundTable, DRWSound, Random)` is typed (`src/AcDream.Core/Audio/SoundCookbook.cs:74-82`, alias at :4). The Core `SoundId` enum needs **no change**.
|
||
> - **Add a confirm step** below to pin the exact member spelling before use.
|
||
|
||
Pinned chain elements (all read from source):
|
||
- `_dats.Portal.Header.MasterMapId` + `_dats.Portal.TryGet<EnumIDMap>(...)` — the EnumIDMap pattern (`src/AcDream.App/UI/IconComposer.cs:86-95` for the master→sub two-level lookup).
|
||
- `_soundCache.GetSoundTable(uint)` (`src/AcDream.Core/Audio/DatSoundCache.cs:59-66`) → `SoundTable?`.
|
||
- `SoundCookbook.Roll(SoundTable, DRWSound, Random)` → `SoundEntry?` (:74); winning `entry.Id` is the Wave id (used exactly so at `src/AcDream.App/Audio/AudioHookSink.cs:113`).
|
||
- `_soundCache.GetWave(uint)` → `WaveData?` (`DatSoundCache.cs:39`).
|
||
- `_audioEngine.PlayUiWave(uint waveId, WaveData wave, float volume=1, float pitch=1)` (`src/AcDream.App/Audio/OpenAlAudioEngine.cs:278`).
|
||
- Fields `_dats` (`:28`), `_audioEngine` (`:250`), `_soundCache` (`:251`).
|
||
|
||
The UI SoundTable id resolves via `MasterMapId → ClientEnumToID[0x10000003] → ClientEnumToID[7]` per the design's §8 chain. This chain is **medium-confidence** (not yet runtime-confirmed), so the implementation must fail gracefully (no sound + one warning log) if any link misses — that is the design's required fallback, not a workaround.
|
||
|
||
- [ ] **Step 0 (confirm the enum spelling — required before writing code):** read the member names by greping the dat lib so the case `DRWSound.EnterPortal`/`DRWSound.ExitPortal` compiles. The build error pins exact casing in YOUR build; if casing differs, use the build error to correct it — do not guess a third spelling.
|
||
|
||
- [ ] **Step 1 (no separate unit test):** This is GameWindow integration (god object) and the resolution depends on live dats — it's build-green + manual, like 4.1. (The reusable picker logic — `SoundCookbook.Roll` — is already unit-covered.) Skip Steps 1-2 of the TDD template; go straight to implement + build + manual-verify.
|
||
|
||
- [ ] **Step 3: Implement** in GameWindow:
|
||
|
||
```csharp
|
||
private enum PortalSoundKind { Enter, Exit }
|
||
|
||
// Phase B (retail teleport flow): play the EnterPortal / ExitPortal UI sound.
|
||
// Resolves the UI SoundTable via the master EnumIDMap chain (design §8):
|
||
// Portal.Header.MasterMapId -> EnumIDMap.ClientEnumToID[0x10000003] -> [7] -> SoundTable id
|
||
// then rolls the EnterPortal/ExitPortal entry and plays its Wave on the UI pool.
|
||
// Medium-confidence chain: if any link misses, log once + play nothing (the design's
|
||
// required graceful fallback, not a workaround — the visual flow proceeds regardless).
|
||
private uint _uiSoundTableId; // 0 = unresolved/unknown; resolved lazily on first portal sound
|
||
private bool _uiSoundTableTried;
|
||
|
||
private void PlayPortalSound(PortalSoundKind kind)
|
||
{
|
||
if (_audioEngine is null || !_audioEngine.IsAvailable || _soundCache is null || _dats is null)
|
||
return;
|
||
|
||
uint tableId = ResolveUiSoundTableId();
|
||
if (tableId == 0) return;
|
||
|
||
var table = _soundCache.GetSoundTable(tableId);
|
||
if (table is null) return;
|
||
|
||
var sound = kind == PortalSoundKind.Enter
|
||
? DatReaderWriter.Enums.Sound.EnterPortal
|
||
: DatReaderWriter.Enums.Sound.ExitPortal;
|
||
|
||
var entry = AcDream.Core.Audio.SoundCookbook.Roll(table, sound, Random.Shared);
|
||
if (entry is null) return;
|
||
|
||
var wave = _soundCache.GetWave((uint)entry.Id);
|
||
if (wave is null) return;
|
||
|
||
_audioEngine.PlayUiWave((uint)entry.Id, wave,
|
||
volume: System.Math.Clamp(entry.Volume > 0 ? entry.Volume : 1f, 0f, 1f));
|
||
}
|
||
|
||
private uint ResolveUiSoundTableId()
|
||
{
|
||
if (_uiSoundTableTried) return _uiSoundTableId;
|
||
_uiSoundTableTried = true;
|
||
if (_dats is null) return 0;
|
||
|
||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
|
||
if (masterDid == 0
|
||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|
||
|| !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid)
|
||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)
|
||
|| !sub.ClientEnumToID.TryGetValue(7u, out var tableId))
|
||
{
|
||
Console.WriteLine("[teleport] UI SoundTable EnumIDMap chain did not resolve — portal sounds disabled (no-op).");
|
||
return 0;
|
||
}
|
||
_uiSoundTableId = tableId;
|
||
return tableId;
|
||
}
|
||
```
|
||
|
||
Then in `EnsureTeleportFlowController` (Task 3.3), replace the temporary empty sound delegates with `() => PlayPortalSound(PortalSoundKind.Enter)` / `() => PlayPortalSound(PortalSoundKind.Exit)`.
|
||
|
||
- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. (If `DatReaderWriter.Enums.Sound.EnterPortal` / `.ExitPortal` fails to resolve, the build error pins the real casing — fix it from the error, do not guess.)
|
||
- [ ] **Step: MANUAL/VISUAL (audio) verification.** Portal in: expect the retail EnterPortal whoosh at the start of the fade; expect the ExitPortal sound at the tunnel→world fade-in edge. If the log prints "UI SoundTable EnumIDMap chain did not resolve", the §8 index guess (`0x10000003`→`[7]`) is wrong — capture the real chain via a cdb trace / dat dump and correct the two constants (do NOT add a hardcoded table id workaround).
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 4 — EnterPortal/ExitPortal UI sounds via master EnumIDMap chain, graceful no-sound fallback"`
|
||
|
||
> Divergence-register: no new row for sounds (faithful port). If the §8 chain stays unresolved and you ship with sounds disabled, add a stopgap row noting "portal sounds pending EnumIDMap-chain runtime confirmation" and delete it when resolved.
|
||
|
||
---
|
||
|
||
### Slice 5: Unify login + death + logout (de-dup readiness; logout command)
|
||
|
||
#### Task 5.1 — De-dup the login readiness predicate onto `TeleportArrivalRules.Decide`
|
||
|
||
**Files:**
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — the `isSpawnGroundReady` login predicate (`:1036-1065`).
|
||
- Test: extend `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs` (the slice-2 `Decide(claimUnhydratable, indoor, indoorCellReady, outdoorReady)` 4-arg form).
|
||
|
||
The login predicate (`:1036-1064`) and `TeleportArrivalReadiness` (`:5513-5530`) were verbatim duplicates of the readiness logic. Slice 2 already rewrote `TeleportArrivalReadiness` to call `TeleportArrivalRules.Decide(..., outdoorReady)` with `outdoorReady` from `IsLandblockLoaded`. This task collapses the LOGIN predicate to call the SAME `Decide`, computing its `outdoorReady` arg from `SampleTerrainZ(...) != null` (login's existing outdoor gate). Do NOT create a separate `ComputeArrivalReadiness` method (the prompt forbids it) — both sites call `Decide` directly.
|
||
|
||
- [ ] **Step 1: Write the failing test** — add the outdoor-axis case proving `Decide` honors `outdoorReady` (this guards the shared method the login predicate will now depend on):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Outdoor_NotLoaded_HoldsNotReady()
|
||
{
|
||
// Slice 2 axis: outdoor now HOLDS until the landblock is loaded (folds in #145 residual).
|
||
Assert.Equal(ArrivalReadiness.NotReady,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: false));
|
||
}
|
||
|
||
[Fact]
|
||
public void Outdoor_Loaded_IsReady()
|
||
{
|
||
Assert.Equal(ArrivalReadiness.Ready,
|
||
TeleportArrivalRules.Decide(
|
||
claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: true));
|
||
}
|
||
```
|
||
|
||
(If slice 2 already added these exact cases, instead add a login-shaped case asserting the indoor branch ignores `outdoorReady`: `Decide(false, true, true, false) == Ready`. Read the current `TeleportArrivalRulesTests.cs` first and only add what's missing — do not duplicate.)
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`. Expected: FAIL if the 4-arg `Decide` cases aren't present yet (compile/assert), or PASS-already if slice 2 covered them (then this task is the GameWindow rewrite only — note it).
|
||
|
||
- [ ] **Step 3: Implement** — rewrite the login predicate body (`:1036-1064`) to delegate to `Decide`:
|
||
|
||
```csharp
|
||
isSpawnGroundReady: () =>
|
||
{
|
||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
|
||
|
||
// #135/#145 (de-dup §3.5): the login spawn-readiness verdict is the SAME decision as a
|
||
// teleport arrival — route both through TeleportArrivalRules.Decide. Indoor gates on the
|
||
// EnvCell floor; outdoor gates on the terrain under the spawn having streamed in
|
||
// (SampleTerrainZ != null — login's existing outdoor gate, the async equivalent of
|
||
// retail's synchronous cell load).
|
||
uint claim = (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||
&& sp.Position is { } spawnClaim && spawnClaim.LandblockId != 0)
|
||
? spawnClaim.LandblockId : 0u;
|
||
|
||
bool claimUnhydratable = claim != 0 && IsSpawnClaimUnhydratable(claim);
|
||
bool indoor = claim != 0 && (claim & 0xFFFFu) >= 0x0100u;
|
||
bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(claim);
|
||
bool outdoorReady = _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
|
||
|
||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||
claimUnhydratable, indoor, indoorCellReady, outdoorReady) == AcDream.App.World.ArrivalReadiness.Ready;
|
||
},
|
||
```
|
||
|
||
(Read `:1036-1064` first to preserve the exact field names — `_lastSpawnByGuid`, `sp.Position`, `IsSpawnClaimUnhydratable`, `IsSpawnCellReady`, `SampleTerrainZ` are all confirmed in the current body. The original returned `bool`; `Decide(...) == Ready` preserves that — login holds on `NotReady`/`Impossible`, places on `Ready`. **Preserve the old un-hydratable-indoor truth table:** if the old code demoted an un-hydratable indoor claim to the outdoor terrain gate (placed on `SampleTerrainZ != null`) rather than holding, treat `claimUnhydratable && indoor` as "use outdoorReady" — read the old branch and reproduce its exact behavior; do not silently change login placement timing.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes** — Rules `--filter` PASS; then `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
|
||
- [ ] **Step: MANUAL/VISUAL verification.** Log in fresh (outdoor spawn at Holtburg) and into a dungeon: both must reach in-world FPS-steady with no free-fall (the #106/#135 invariant the predicate guards). Regression-gate: login stays FPS-steady (the design's explicit login regression gate).
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "refactor(teleport): Slice 5 — de-dup login spawn-readiness onto TeleportArrivalRules.Decide (§3.5)"`
|
||
|
||
---
|
||
|
||
#### Task 5.2 — Route LOGIN + DEATH through the flow controller
|
||
|
||
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` — `EnterPlayerModeFromAutoEntry` success branch (`:2610-2624`); `OnTeleportStarted` (`:5579`, death confirmation comment).
|
||
|
||
Anchors:
|
||
- `EnterPlayerModeFromAutoEntry` at `:2610`; its success branch logs "auto-entered player mode" at `:2622`.
|
||
- `EnterPlayerModeNow` returns true after setting up `_playerController` with `Position` (`PlayerMovementController.Position` => `_body.Position`, `src/AcDream.App/Input/PlayerMovementController.cs:131`) and `CellId` (`:133`).
|
||
- Death: server `0xF751` teleport-to-lifestone already routes through `OnTeleportStarted` → `Begin(Portal)` (Task 3.3). NO new death code.
|
||
|
||
- [ ] **Step 1 (LOGIN edit):** In `EnterPlayerModeFromAutoEntry` success branch (`:2620-2623`), after the `Console.WriteLine($"live: auto-entered player mode ...")`:
|
||
|
||
```csharp
|
||
// Phase B (retail teleport flow) §4: route LOGIN through the same TAS flow. The world is
|
||
// already loaded (auto-entry only fires once isSpawnGroundReady passed), so the readiness
|
||
// probe returns Ready immediately -> the tunnel exits fast -> continue + fade-in plays.
|
||
// onPlace re-resolves the player position harmlessly (idempotent). This gives login the
|
||
// retail fade-in instead of a hard pop-in.
|
||
EnsureTeleportFlowController();
|
||
_teleportFlow!.Begin(
|
||
AcDream.Core.World.TeleportEntryKind.Login,
|
||
_playerController!.Position,
|
||
_playerController.CellId);
|
||
```
|
||
|
||
- [ ] **Step 2 (DEATH comment):** Above `OnTeleportStarted` (`:5579`), add a confirming comment (no code):
|
||
|
||
```csharp
|
||
// Phase B §4: DEATH needs NO dedicated code. ACE teleports the corpse owner to the
|
||
// lifestone after the death anim (Player_Death.cs:247 / Player_Location.cs:686); the client
|
||
// observes it as an ordinary 0xF751 teleport, which already enters here -> Begin(Portal) ->
|
||
// TUNNEL. The large-position-jump branch of OnLivePositionUpdated is a second safety net.
|
||
```
|
||
|
||
- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||
- [ ] **Step: MANUAL/VISUAL verification.** (1) Login: expect a short black hold → fade-in (NOT an instant pop) and FPS-steady. (2) Death (let the character die against a creature, or use a GM kill): expect death anim → fade → respawn at the lifestone with the same fade-in. Confirm LoginComplete is sent after login fade-in (the server accepts subsequent movement). **If death shows NO fade** (the teleport arrived without a preceding `0xF751`, so `OnTeleportStarted` never fired): apply the open-question #1 contingency — add a large-position-jump → `PortalSpace` + `Begin(Death)` trigger in `OnLivePositionUpdated`. File it as a follow-up task; do not block the slice.
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — route login + death through TeleportFlowController (Login enters at TUNNEL, exits fast)"`
|
||
|
||
---
|
||
|
||
#### Task 5.3 — LOGOUT command (animation + 0xF653 + clean disconnect)
|
||
|
||
**Files:**
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — `OnInputAction` switch (`:11442` method; add a `case` near `:11646`).
|
||
- Test: extend `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (the `Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete` case in Task 3.1 already covers the controller side — this task is the input wiring + the disconnect delegate, which is integration).
|
||
|
||
Confirmed:
|
||
- `InputAction.LOGOUT` exists (`src/AcDream.UI.Abstractions/Input/InputAction.cs:100`) with a retail default keybind **Shift+Escape** (`src/AcDream.UI.Abstractions/Input/KeyBindings.cs:212`) — so NO new action and NO new keybind needed.
|
||
- `OnInputAction` filters to Press/DoubleClick before the switch (`:11501`), so a `case` fires on Press.
|
||
- Logout teardown = `_liveSessionController?.Dispose()` → `LiveSessionController.Dispose()` (`src/AcDream.App/Net/LiveSessionController.cs:87`) → `WorldSession.Dispose()` (`src/AcDream.Core.Net/WorldSession.cs:1319`) which sends `0xF653` CharacterLogOff (`:1328`) then DISCONNECT. The flow controller's `onLogoutDisconnect` delegate (wired in Task 3.3) already calls exactly this.
|
||
|
||
- [ ] **Step 1 (no new unit test):** the controller's Logout behavior is unit-covered by `Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete` (Task 3.1). The input-case wiring is GameWindow integration (build-green + manual). Confirm the existing 3.1 test passes: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests.Logout"`. Expected: PASS (already implemented in Slice 3).
|
||
|
||
- [ ] **Step 2 (edit):** Add a `case` to the `OnInputAction` switch, before the closing brace at `:11647` (after the `EscapeKey` case at `:11632-11646`):
|
||
|
||
```csharp
|
||
case AcDream.UI.Abstractions.Input.InputAction.LOGOUT:
|
||
// Phase B §4: retail logout = the TAS animation starting at WORLD_FADE_OUT, then 0xF653
|
||
// CharacterLogOff + clean disconnect (via WorldSession.Dispose, fired by the controller's
|
||
// onLogoutDisconnect delegate at sequencer OFF). Char-select-return UI is deferred (§9 /
|
||
// register row 4). Idempotent: ignore if a flow is already running or we're offline.
|
||
if (_liveSession is not null
|
||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
|
||
&& (_teleportFlow is null || !_teleportFlow.IsActive))
|
||
{
|
||
if (_playerController is not null)
|
||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace; // lock input for the whole fade
|
||
EnsureTeleportFlowController();
|
||
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Logout);
|
||
}
|
||
break;
|
||
```
|
||
|
||
- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||
- [ ] **Step: MANUAL/VISUAL verification.** In-world, press Shift+Esc. Expect: world fades to black (WORLD_FADE_OUT → TUNNEL_FADE_IN front segments — Logout's ~6s budget) → at sequencer OFF the client sends `0xF653` and disconnects cleanly (the window closes / session ends; verify via WireMCP capture on loopback that a `0xF653` CharacterLogOff frame goes out before the DISCONNECT, and that ACE clears the session promptly — graceful close, not a hard kill). No char-select screen (deferred, §9).
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — LOGOUT (Shift+Esc) plays the TAS fade then sends 0xF653 + disconnects"`
|
||
|
||
---
|
||
|
||
#### Task 5.4 — Remove the now-dead `TeleportArrivalController` plumbing + add divergence-register row 4
|
||
|
||
**Files:**
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — remove the unused `_teleportArrival` field, `EnsureTeleportArrivalController`, and the `BeginArrival` call path (all superseded by `_teleportFlow` after slices 3/5).
|
||
- `docs/architecture/retail-divergence-register.md` — add row 4 (logout → disconnect, not char-select).
|
||
|
||
- [ ] **Step 1: Confirm `_teleportArrival` is unreferenced except its own decls** — `rg -n "_teleportArrival|EnsureTeleportArrivalController" src/AcDream.App/Rendering/GameWindow.cs`. Expected: only the field declaration, the (now-shimmed) ensure, and no live `.Tick()` / `.BeginArrival()` callers (slices 3/5 replaced them with `_teleportFlow`). If a live caller remains, route it through `_teleportFlow` first.
|
||
|
||
- [ ] **Step 2: Remove the dead plumbing** — delete the `_teleportArrival` field decl, the `EnsureTeleportArrivalController` method, and any `EnsureTeleportArrivalController();` calls left in `OnTeleportStarted` (the `EnsureTeleportFlowController()` call from Task 3.3 supersedes it). Keep `TeleportArrivalController.cs` (the class + `TeleportArrivalRules`) — `TeleportArrivalRules.Decide` is still used by `TeleportArrivalReadiness`; only the unused instance plumbing in GameWindow goes.
|
||
|
||
- [ ] **Step 3: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Then full suite: `dotnet test AcDream.slnx -c Debug`.
|
||
|
||
- [ ] **Step 4: Add divergence-register row 4** — in `docs/architecture/retail-divergence-register.md`, matching the existing column order:
|
||
|
||
```
|
||
| Logout returns to disconnect/window-close, not a char-select screen | Stopgap | Retail shows char-select after logout; acdream closes the session. Retire when the char-select / re-login UI is built (design §9). | src/AcDream.App/World/TeleportFlowController.cs (onLogoutDisconnect) | No char-select after logout; user must relaunch to reconnect. |
|
||
```
|
||
|
||
- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md && git commit -m "refactor(teleport): Slice 5 — remove dead _teleportArrival plumbing; divergence row 4 (logout→disconnect)"`
|
||
|
||
---
|
||
|
||
**Cross-slice consistency notes (the parallel-draft fixes):**
|
||
1. **One sound-enum decision, stated once:** `DRWSound.EnterPortal`/`ExitPortal` (NOT `UI_*=0x6A/0x6B`); Core `SoundId` enum untouched. Owned by Task 4.2; referenced nowhere else.
|
||
2. **One yaw-freeze, in Slice 4 only** (Task 4.1) — a single early-return at `GameWindow.cs:1095` covering both MMB + RMB sub-branches; not duplicated in 3 or 5.
|
||
3. **PlaceTeleportArrival split happens once** (Task 3.3): `PlaceTeleportArrivalCore` (placement only) + `OnTeleportLoginComplete` (State=InWorld + LoginComplete). Slices 4/5 consume these, never re-split.
|
||
4. **`_teleportArrival` removal is the explicit Task 5.4** once login no longer uses the old `isSpawnGroundReady`→`BeginArrival` path — avoids a half-removed field across slices.
|
||
5. **`Begin` idempotency** (sequencer not restarted by the second coords-carrying `Begin`) is the contract that makes Portal's two-call trigger (OnTeleportStarted no-coords + OnLivePositionUpdated coords) safe — unit-pinned in Task 3.1.
|
||
|
||
---
|
||
|
||
## Slice 6 (FOLLOW-UP PLAN — not in this plan): the literal 3D portal swirl
|
||
|
||
The retail tunnel is a 3D sub-viewport (`UIElement_Viewport` Type 0xD) rendering a portal `CPhysicsObj` (dat `GetDIDByEnum(0x10000001,7)` setup + `(0x10000002,7)` animation, 40 fps, randomized camera spin) shown during the `Tunnel*` states. acdream has **no** offscreen-3D / sub-viewport infrastructure, and the asset ids only resolve via a runtime trace. This slice is therefore its **own** spec→plan, executed in the same pass **after** its prerequisite:
|
||
|
||
- **Prerequisite (cdb trace):** attach cdb to a live retail client (see CLAUDE.md "Retail debugger toolchain"), `bp acclient!CPhysicsObj::makeObject` during a portal, and capture the resolved `GetDIDByEnum(0x10000001,7)` / `(0x10000002,7)` dat ids. Also capture `UIGlobals::GetAnimLevel`'s 1024-entry table if the fade ease needs to be exact (else smoothstep stands — register row TF-EASE).
|
||
- **Then:** build a `PortalTunnelView` (offscreen 3D render of the swirl `CPhysicsObj` at 40 fps with the randomized camera rotation) and blit it during `Snapshot.ShowTunnel`; the sequencer/overlay seams already exist (`ShowTunnel`/`ShowPleaseWait` flags are wired in slices 1/3). Until then the tunnel states render as the black fade-hold — a coherent, retail-shaped flow.
|
||
|
||
This staging is the design's §7 slice 6 + §8 prerequisites. It does **not** block slices 1–5, which ship the complete behavioral feature (float gone, input locked, hold-until-loaded, sounds, the four entry points, the #145 fix).
|
||
|
||
## Open questions / risks (resolve during execution; none block plan start)
|
||
|
||
1. **Death trigger (verify in Slice 5 Task 5.2).** Death is assumed to arrive as a server `0xF751` teleport → `OnTeleportStarted` → `Begin(Portal)`. The research could not confirm from acdream code alone that ACE always sends `0xF751` before the lifestone `UpdatePosition`. **Gate:** the Task 5.2 manual death test. **Contingency if it fails (no fade on death):** add a large-position-jump detector in `OnLivePositionUpdated` that sets `PortalSpace` + calls `_teleportFlow.Begin(TeleportEntryKind.Death, …)` when the player jumps landblocks with no preceding `0xF751`. File it as a follow-up task only if the manual test shows death bypassing the flow.
|
||
2. **Portal sound enum casing + the EnumIDMap chain (Slice 4 Task 4.2).** The member is `DatReaderWriter.Enums.Sound.EnterPortal`/`ExitPortal` (verified against the dat lib; the design doc's `UI_EnterPortal=0x6A` is wrong). If the build still can't resolve it, the compile error pins the exact casing — fix from the error, don't guess. The UI-SoundTable EnumIDMap index chain (`0x10000003`→`[7]`) is medium-confidence; the implementation logs once and plays no sound if it misses (graceful, per design) — capture the real chain via cdb/dat-dump and correct the two constants rather than hardcoding a table id.
|
||
3. **Login `Impossible` truth-table (Slice 5 Task 5.1).** Preserve the old login predicate's exact behavior for an un-hydratable indoor claim (it demoted to the outdoor terrain gate). Read `GameWindow.cs:1036-1064` before rewriting and confirm `Decide(...) == Ready` reproduces it; if the old code placed (not held) on an un-hydratable indoor claim, keep that. **Regression gate:** login stays FPS-steady (the #106/#135 invariant).
|
||
4. **Apparatus gate for the #145 fix (Slice 2 Task 2.5).** Before relying on the readiness-gate change, run the `ACDREAM_PROBE_RESOLVE` apparatus on a `0xC98C`-style edge arrival and confirm the gate flips `Ready` *before* placement with no `NO-LANDBLOCK` march. This is the "no guess-patch" gate (the original #145 burned 5 attempts). Tracked as task #4 in the session.
|
||
5. **`DatReaderWriter` source-of-truth.** Confirm whether acdream references the vendored `references/DatReaderWriter` or the `chorizite.datreaderwriter` NuGet DLL — the two disagreed on the Sound enum member names during research. The build resolves this at Slice 4.
|
||
|
||
## Phase completion checklist (per CLAUDE.md)
|
||
|
||
- [ ] Every AC-specific algorithm cites its decomp reference (the sequencer constants cite `acclient.h:6871` + the VAs from the design §2.1).
|
||
- [ ] Every deviation this work introduces has a `retail-divergence-register.md` row (TF-FADE alpha overlay, TF-EASE smoothstep, logout→disconnect; tasks 1.7 / 3.x / 5.3 add them).
|
||
- [ ] Conformance tests exist for the critical paths (sequencer golden timings; `Decide` truth table; controller event dispatch).
|
||
- [ ] `dotnet build` green, `dotnet test` green.
|
||
- [ ] Visual verification by the user (the mandatory stop): portal feel, login fade-in, death→lifestone, logout fade.
|
||
- [ ] Roadmap / milestones updated when the feature lands; ISSUES #145 residual closed if the apparatus re-test passes.
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
**Plan complete.** Two execution options:
|
||
|
||
1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration (REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`). Best for a plan this size (it isolates the god-object wiring tasks from the pure-logic tasks).
|
||
2. **Inline Execution** — execute tasks in this session with batched checkpoints (REQUIRED SUB-SKILL: `superpowers:executing-plans`).
|
||
|
||
**Recommended order:** Slice 1 → Slice 2 (lands the #145 fix; run the apparatus gate) → Slice 3 → Slice 4 → Slice 5. Each slice ends build+test green; slices 3/5 end with a visual stop.
|