using AcDream.Core.World; using Xunit; 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); } // --- Task 1.2: Begin(), IsActive, initial state --- [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); } // --- 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); } // --- Task 1.4: FadeAlpha endpoints and monotonicity --- [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); } // --- Task 1.5: Full portal event sequence and logout event sequence --- // Helper: run the sequencer to completion collecting ordered events. private static List RunToOff( TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f) { var seq = new TeleportAnimSequencer(); var allEvts = new List(); 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); } // --- Task 1.6: EnterTunnel fired for Portal path via Begin() --- [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); } }