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,155 @@
using System;
namespace AcDream.Core.World;
/// <summary>
/// Asheron's Call "Portal Year" calendar + time-of-day math — faithful
/// port of retail's <c>DerethDateTime</c> (r12 §1.2 + ACE
/// <c>DerethDateTime.cs</c>).
///
/// <para>
/// The server transports an absolute tick count as a double (seconds
/// since a seed chosen at boot). The client doesn't need to know which
/// seed was chosen — it just needs the tick count to derive:
/// <list type="bullet">
/// <item><description>
/// Day fraction in [0, 1): where we are in the 16-hour Derethian day.
/// </description></item>
/// <item><description>
/// Hour name (Darktide, Dawnsong, Midsong, Warmtide, Evensong,
/// Gloaming and their "-and-Half" mid-hour variants).
/// </description></item>
/// <item><description>
/// Day / night flag for spawn rules + lighting.
/// </description></item>
/// <item><description>
/// Season / year / month for calendar UI.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Constants are retail-exact; changing them breaks the calendar panel's
/// "today is" display.
/// </para>
/// </summary>
public static class DerethDateTime
{
public const int HoursInADay = 16;
public const int DaysInAMonth = 30;
public const int MonthsInAYear = 12;
/// <summary>Ticks per Derethian day (matches retail GameTime.DayLength).</summary>
public const double DayTicks = 7620.0;
/// <summary>Ticks per Derethian hour (DayTicks / 16).</summary>
public const double HourTicks = DayTicks / HoursInADay; // 476.25
/// <summary>Ticks per Derethian month.</summary>
public const double MonthTicks = DayTicks * DaysInAMonth; // 228,600
/// <summary>Ticks per Derethian year.</summary>
public const double YearTicks = MonthTicks * MonthsInAYear; // 2,743,200
/// <summary>
/// Above this tick count the retail client crashes on connect.
/// ~1.07 billion seconds = PY 401 Thistledown 2 Morntide-and-Half.
/// </summary>
public const double MaxTicks = 1_073_741_828.0;
/// <summary>
/// The 16 named hour slots (r12 §1.2). Each one is half a Derethian
/// hour; the "-and-Half" variants are the second half.
/// </summary>
public enum HourName
{
Darktide = 0,
DarktideAndHalf,
Foredawn,
ForedawnAndHalf,
Dawnsong, // day starts here (hour 5)
DawnsongAndHalf,
Morntide,
MorntideAndHalf,
Midsong,
MidsongAndHalf,
Warmtide,
WarmtideAndHalf, // day ends here (hour 12)
Evensong,
EvensongAndHalf,
Gloaming,
GloamingAndHalf,
}
/// <summary>Derethian months (Snowreap..Frostfell, 12 total).</summary>
public enum MonthName
{
Snowreap = 0,
ColdMeet,
Leafdawning,
Seedsow,
Rosetide,
Solclaim,
Thistledown,
Harvestgain,
Leaftrue,
Reaptide,
Morningthaw,
Frostfell,
}
/// <summary>
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
/// Midsong-and-Half (noon-ish), 1.0 wraps to 0.
/// </summary>
public static double DayFraction(double ticks)
{
if (ticks < 0) ticks = 0;
double rem = ticks - Math.Floor(ticks / DayTicks) * DayTicks;
return rem / DayTicks;
}
/// <summary>
/// Hour name for the given tick count. Picked from
/// <see cref="HourName"/> based on which named half-hour slot the
/// current time falls in.
/// </summary>
public static HourName CurrentHour(double ticks)
{
double f = DayFraction(ticks);
int slot = (int)Math.Floor(f * HoursInADay);
if (slot < 0) slot = 0;
if (slot > 15) slot = 15;
return (HourName)slot;
}
/// <summary>
/// True when the current time is "day" (Dawnsong through
/// Warmtide-and-Half, hours 512 inclusive on the 16-hour scale).
/// </summary>
public static bool IsDaytime(double ticks)
{
int h = (int)CurrentHour(ticks);
return h >= (int)HourName.Dawnsong && h <= (int)HourName.WarmtideAndHalf;
}
/// <summary>
/// Derethian calendar breakdown: (year, month, day, hour).
/// Year starts at PY 0. Day is 1-based within the month (1..30).
/// </summary>
public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour);
public static Calendar ToCalendar(double ticks)
{
if (ticks < 0) ticks = 0;
int year = (int)(ticks / YearTicks);
double tYear = ticks - year * YearTicks;
int monthIdx = (int)(tYear / MonthTicks);
if (monthIdx > 11) monthIdx = 11;
double tMonth = tYear - monthIdx * MonthTicks;
int day = (int)(tMonth / DayTicks) + 1;
if (day > DaysInAMonth) day = DaysInAMonth;
return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks));
}
}