acdream/tests/AcDream.Core.Tests/World/SkyStateTests.cs
Erik 6850d716a2 feat(world): Phase G.1 DerethDateTime + SkyStateProvider + WorldTimeService
Client-side deterministic sky + weather + day/night system per R12.
Retail's model is 95% client-side: the server just delivers its
current PortalYearTicks (double, seconds since boot-seed) at login and
in TimeSync packets; the client computes everything else locally from
the constants in r12 §1.2 + ACE DerethDateTime.cs.

Core layer (AcDream.Core/World):
- DerethDateTime: retail-exact calendar (16 hours/day, 30 days/month,
  12 months/year, 7620 ticks/day, 2,743,200 ticks/year). HourName enum
  covers all 16 named half-hour slots (Darktide → GloamingAndHalf);
  MonthName covers the 12 Derethian months (Snowreap → Frostfell).
  DayFraction, CurrentHour, IsDaytime, ToCalendar.
- SkyKeyframe + SkyStateProvider: 4-keyframe default day/dawn/noon/dusk
  with linear color + angular-wrap heading interpolation + slerp-like
  shortest-arc lerp so heading wraps 350° → 10° don't tween backwards
  through 180°. Default keyframe colors tuned to retail screenshots
  (sunrise warm, noon white, sunset red, midnight deep blue).
- WorldTimeService: owns the live clock. SyncFromServer(ticks) sets
  baseline; NowTicks advances by real-time elapsed. Exposes DayFraction,
  CurrentSky, CurrentSunDirection, IsDaytime for the render thread.

This is the foundation Phase G.2 (dynamic lighting) consumes: lighting
uniforms are fed from CurrentSky's SunColor / AmbientColor / sun
direction, varying smoothly across the day.

Tests (16 new):
- DerethDateTime: midnight, half-day, wrap, Dawnsong, Midsong,
  day/night flag at dawn vs Darktide-Half, year rollover, month
  advance.
- SkyState: 4-default keyframes, noon-exact matches frame data,
  midpoint lerps between neighbours, wrap across midnight doesn't
  produce NaN, sun direction returns unit vector, WorldTimeService
  sync + DayFraction at noon.

Build green, 587 tests pass (up from 570).

Ref: r12 §1 (Portal Year math), §2 (sky objects), §4 (color lerp).
Ref: ACE DerethDateTime.cs + NetworkSession TimeSync handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:07:26 +02:00

89 lines
2.8 KiB
C#

using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class SkyStateTests
{
[Fact]
public void Default_Has4Keyframes()
{
var sky = SkyStateProvider.Default();
Assert.Equal(4, sky.KeyframeCount);
}
[Fact]
public void Interpolate_AtExactKeyframe_ReturnsThatFrameData()
{
var sky = SkyStateProvider.Default();
var noon = sky.Interpolate(0.5f); // noon keyframe
// Noon sky color should be near white (1.0 ish).
Assert.InRange(noon.SunColor.X, 0.9f, 1.1f);
Assert.InRange(noon.SunColor.Y, 0.9f, 1.1f);
}
[Fact]
public void Interpolate_BetweenKeyframes_LerpsColors()
{
var sky = SkyStateProvider.Default();
var dawn = sky.Interpolate(0.25f);
var noon = sky.Interpolate(0.5f);
var midPt = sky.Interpolate(0.375f);
// Midpoint should fall between dawn & noon for sun color Y (green channel).
float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y);
float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y);
Assert.InRange(midPt.SunColor.Y, low, high);
}
[Fact]
public void Interpolate_Wraps_AcrossMidnight()
{
var sky = SkyStateProvider.Default();
var justAfterMidnight = sky.Interpolate(0.01f);
// Should return finite valid state (not NaN).
Assert.False(float.IsNaN(justAfterMidnight.SunColor.X));
Assert.False(float.IsNaN(justAfterMidnight.AmbientColor.X));
}
[Fact]
public void SunDirectionFromKeyframe_ReturnsUnitVector()
{
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south
SunPitchDeg: 70f,
SunColor: Vector3.One,
AmbientColor: Vector3.One,
FogColor: Vector3.One,
FogDensity: 0.001f);
var dir = SkyStateProvider.SunDirectionFromKeyframe(kf);
Assert.InRange(dir.Length(), 0.99f, 1.01f);
}
[Fact]
public void WorldTimeService_SyncFromServer_SetsTicks()
{
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(12345.0);
// NowTicks advances by real elapsed time; but immediately after
// sync it should be at or very close to the synced value.
Assert.InRange(service.NowTicks, 12345.0, 12346.0);
}
[Fact]
public void WorldTimeService_DayFraction_RespectsSync()
{
var service = new WorldTimeService(SkyStateProvider.Default());
// Sync to exactly noon of day 0.
service.SyncFromServer(DerethDateTime.DayTicks * 0.5);
Assert.InRange(service.DayFraction, 0.499, 0.501);
Assert.True(service.IsDaytime);
}
}