diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 7836e879..c786ea13 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -121,7 +121,7 @@ public sealed class TeleportAnimSequencer case TeleportAnimState.TunnelContinue: _continueElapsed += dt; - bool minMet = _continueElapsed >= MinContinue; + bool minMet = worldReady && _continueElapsed >= MinContinue; bool maxForce = _continueElapsed >= MaxContinue; if (minMet || maxForce) Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false); diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index 425af9b2..f38ba60e 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -68,4 +68,172 @@ public sealed class TeleportAnimSequencerTests var (_, events) = seq.Tick(dt: 0f, worldReady: false); Assert.Contains(TeleportAnimEvent.PlayEnterSound, events); } + + // --- Task 1.3: State transitions — timing-correct Tick() --- + + // Helper: drive the sequencer forward by total elapsed time using small fixed steps. + private static (TeleportAnimSnapshot snap, List allEvents) + DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f) + { + var allEvts = new List(); + 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); + } }