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:
parent
404cab55ba
commit
6850d716a2
4 changed files with 566 additions and 0 deletions
86
tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
Normal file
86
tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
tests/AcDream.Core.Tests/World/SkyStateTests.cs
Normal file
89
tests/AcDream.Core.Tests/World/SkyStateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue