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>
This commit is contained in:
Erik 2026-04-18 17:07:26 +02:00
parent 404cab55ba
commit 6850d716a2
4 changed files with 566 additions and 0 deletions

View file

@ -0,0 +1,86 @@
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class DerethDateTimeTests
{
[Fact]
public void DayFraction_AtMidnight_IsZero()
{
Assert.Equal(0.0, DerethDateTime.DayFraction(0));
}
[Fact]
public void DayFraction_AtHalfDay_IsHalf()
{
Assert.Equal(0.5, DerethDateTime.DayFraction(DerethDateTime.DayTicks / 2), 4);
}
[Fact]
public void DayFraction_WrapsAfterOneDay()
{
Assert.Equal(0.0, DerethDateTime.DayFraction(DerethDateTime.DayTicks), 6);
Assert.Equal(0.25, DerethDateTime.DayFraction(DerethDateTime.DayTicks * 1.25), 4);
}
[Fact]
public void CurrentHour_AtMidnight_IsDarktide()
{
Assert.Equal(DerethDateTime.HourName.Darktide, DerethDateTime.CurrentHour(0));
}
[Fact]
public void CurrentHour_AtDawn_IsDawnsong()
{
// Dawnsong is the 5th slot (0-indexed slot 4) — day-fraction 4/16 = 0.25.
double ticks = DerethDateTime.DayTicks * (4.0 / 16.0);
Assert.Equal(DerethDateTime.HourName.Dawnsong, DerethDateTime.CurrentHour(ticks));
}
[Fact]
public void CurrentHour_AtNoon_IsMidsong()
{
// Midsong is 0-indexed slot 8 → day-fraction 0.5.
double ticks = DerethDateTime.DayTicks * 0.5;
Assert.Equal(DerethDateTime.HourName.Midsong, DerethDateTime.CurrentHour(ticks));
}
[Fact]
public void IsDaytime_Dawn_True()
{
double ticks = DerethDateTime.DayTicks * (4.0 / 16.0); // Dawnsong start
Assert.True(DerethDateTime.IsDaytime(ticks));
}
[Fact]
public void IsDaytime_Night_False()
{
double ticks = DerethDateTime.DayTicks * (1.0 / 16.0); // Darktide-and-Half
Assert.False(DerethDateTime.IsDaytime(ticks));
}
[Fact]
public void ToCalendar_PY0Day1_Snowreap()
{
var cal = DerethDateTime.ToCalendar(0);
Assert.Equal(0, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
Assert.Equal(1, cal.Day);
}
[Fact]
public void ToCalendar_AdvancesCorrectly()
{
// One year from start → PY 1, Snowreap 1.
var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks);
Assert.Equal(1, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
Assert.Equal(1, cal.Day);
// One month into year 1.
var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks);
Assert.Equal(1, cal2.Year);
Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month);
}
}

View file

@ -0,0 +1,89 @@
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);
}
}