refactor(runtime): own world environment state

This commit is contained in:
Erik 2026-07-26 16:45:04 +02:00
parent b972f539f7
commit 902076c0a4
27 changed files with 886 additions and 295 deletions

View file

@ -236,6 +236,8 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeChatView Chat => null!;
public IRuntimeActionView Actions => null!;
public IRuntimeMovementView Movement => null!;
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;

View file

@ -209,6 +209,8 @@ public sealed class GameplayInputCommandControllerTests
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeActionView Actions => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() =>
throw new NotSupportedException();

View file

@ -169,7 +169,7 @@ public sealed class GameWindowSlice8BoundaryTests
"WorldRenderComposition.cs"));
Assert.Contains(
"private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =",
"private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;",
source,
StringComparison.Ordinal);
Assert.Contains(

View file

@ -23,6 +23,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -783,6 +784,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
MovementState = new RuntimeLocalPlayerMovementState();
Environment = new RuntimeWorldEnvironmentState();
MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
@ -833,6 +835,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication,
Actions,
MovementState,
Environment,
WorldReveal,
Clock,
selectionController);
@ -853,6 +856,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public RuntimeLocalPlayerMovementState MovementState { get; }
public RuntimeWorldEnvironmentState Environment { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; }

View file

@ -338,6 +338,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal(0.95f, invalid.FogEndMultiplier);
}
[Fact]
public void DayGroupOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
EmptyEnv()).ForcedDayGroupIndex);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "-1" }))
.ForcedDayGroupIndex);
Assert.Equal(
7,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "7" }))
.ForcedDayGroupIndex);
}
[Fact]
public void DiagnosticFlags_RespectExactValueOne()
{
@ -346,6 +366,7 @@ public sealed class RuntimeOptionsTests
["ACDREAM_DEVTOOLS"] = "1",
["ACDREAM_UNCAPPED_RENDER"] = "1",
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
["ACDREAM_DUMP_SKY"] = "1",
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
@ -354,6 +375,7 @@ public sealed class RuntimeOptionsTests
Assert.True(allOn.DevTools);
Assert.True(allOn.UncappedRendering);
Assert.True(allOn.DumpMoveTruth);
Assert.True(allOn.DumpSky);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
Assert.True(allOn.DumpSceneryZ);

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.World;
using AcDream.Runtime.World;
namespace AcDream.App.Tests.World;
@ -57,25 +58,23 @@ public sealed class WorldEnvironmentControllerTests
}
[Fact]
public void Initialize_WithoutGameTimeRestoresDocumentedFallbackOrigin()
public void Initialize_WithoutGameTimeUsesInstanceFallbackOrigin()
{
double previous = DerethDateTime.OriginOffsetTicks;
try
{
DerethDateTime.SetOriginOffsetFromDat(3600.0);
var controller = CreateController([]);
var first = CreateController([]);
var second = CreateController([]);
controller.Initialize(SingleGroupSky("Sunny", begin: 0f), zeroTimeOfYear: null);
first.Initialize(
SingleGroupSky("Sunny", begin: 0f),
zeroTimeOfYear: 3600.0);
second.Initialize(
SingleGroupSky("Sunny", begin: 0f),
zeroTimeOfYear: null);
Assert.Equal(
DerethDateTime.DayFractionOriginOffsetTicks,
DerethDateTime.OriginOffsetTicks);
Assert.InRange(controller.DayFraction, 0.499f, 0.501f);
}
finally
{
DerethDateTime.SetOriginOffsetFromDat(previous);
}
Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(
DerethDateTime.DayFractionOriginOffsetTicks,
second.WorldTime.Calendar.OriginOffsetTicks);
Assert.InRange(second.DayFraction, 0.499f, 0.501f);
}
[Theory]
@ -143,8 +142,8 @@ public sealed class WorldEnvironmentControllerTests
private static WorldEnvironmentController CreateController(List<string> log) =>
new(
new WorldTimeService(SkyStateProvider.Default()),
new WeatherSystem(),
new RuntimeWorldEnvironmentState(log: log.Add),
forcedDayGroupIndex: null,
log.Add);
private static LoadedSkyDesc SingleGroupSky(string name, float begin)
@ -180,8 +179,8 @@ public sealed class WorldEnvironmentControllerTests
line.Contains("→ DayGroup[", StringComparison.Ordinal);
}
[CollectionDefinition(Name, DisableParallelization = true)]
[CollectionDefinition(Name)]
public sealed class WorldEnvironmentControllerCollection
{
public const string Name = "World environment process-global calendar";
public const string Name = "World environment";
}

View file

@ -115,17 +115,10 @@ public sealed class DerethDateTimeTests
// 291,411,660 - 3600 = 291,408,060
// Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow,
// day 24 1-indexed.
DerethDateTime.SetOriginOffsetFromDat(3600.0);
try
{
var cal = DerethDateTime.ToCalendar(291_408_060.0);
Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
Assert.Equal(24, cal.Day);
}
finally
{
DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks);
}
var calendar = new DerethCalendar(3600.0);
var cal = calendar.ToCalendar(291_408_060.0);
Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
Assert.Equal(24, cal.Day);
}
}

View file

@ -10,6 +10,39 @@ namespace AcDream.Core.Tests.World;
public sealed class SkyDescLoaderTests
{
[Theory]
[InlineData(20, 10, 0, 16)]
[InlineData(20, 10, 83, 5)]
[InlineData(20, 116, 83, 18)]
[InlineData(20, 127, 359, 4)]
public void DayGroupSelector_MatchesNamedRetailGoldenValues(
int count,
int absoluteYear,
int dayOfYear,
int expected)
{
Assert.Equal(
expected,
SkyDayGroupSelector.SelectIndex(
count,
absoluteYear,
daysPerYear: 360,
dayOfYear));
}
[Fact]
public void DayGroupSelector_ForcedIndexIsExplicitInput()
{
Assert.Equal(
7,
SkyDayGroupSelector.SelectIndex(
20,
absoluteYear: 10,
daysPerYear: 360,
dayOfYear: 0,
forcedIndex: 7));
}
/// <summary>
/// Hand-build a Region with a minimal sky descriptor to feed the
/// loader without needing real dat bytes. The LoadFromRegion

View file

@ -29,10 +29,11 @@ public sealed class WorldTimeDebugTests
// fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D
// → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D.
double targetFraction = 1.0 / 16.0; // Darktide-and-Half
double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks;
var service = new WorldTimeService(SkyStateProvider.Default());
double syncTick = targetFraction * DerethDateTime.DayTicks
- service.Calendar.OriginOffsetTicks;
while (syncTick < 0) syncTick += DerethDateTime.DayTicks;
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(syncTick);
service.SetDebugTime(0.5f);
service.ClearDebugTime();

View file

@ -182,6 +182,7 @@ public sealed class GameRuntimeContractTests
InteractionSourceObjectId: 0u,
InteractionTransactions: default),
default,
default,
default);
recorder.AddCheckpoint(stamp, checkpoint);

View file

@ -0,0 +1,196 @@
using System.Numerics;
using AcDream.Core.World;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests.World;
public sealed class RuntimeWorldEnvironmentStateTests
{
[Fact]
public void Initialize_OwnsRetailDayGroupAndOfflineNoon()
{
List<string> log = [];
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider(),
log.Add);
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
Assert.True(state.IsInitialized);
Assert.Equal(0, state.ActiveDayGroupIndex);
Assert.Equal(WeatherKind.Clear, state.Weather.Kind);
Assert.InRange(state.WorldTime.DayFraction, 0.499, 0.501);
Assert.Contains(
log,
value => value.Contains(
"SkyDesc.TickSize=0.8",
StringComparison.Ordinal));
}
[Fact]
public void TwoInstances_IsolateCalendarClockWeatherAndDebugState()
{
var time = new ManualTimeProvider();
var first = new RuntimeWorldEnvironmentState(time);
var second = new RuntimeWorldEnvironmentState(time);
first.Initialize(Definition(3600.0, ("Sunny", Sky(0f))));
second.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Cloudy", Sky(0f))));
first.SynchronizeFromServer(0.0);
second.SynchronizeFromServer(0.0);
_ = first.ApplyAdminEnvirons(0x01u);
_ = first.CycleTimeOfDay();
Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(
DerethDateTime.DayFractionOriginOffsetTicks,
second.WorldTime.Calendar.OriginOffsetTicks);
Assert.Equal(0f, first.WorldTime.DayFraction);
Assert.InRange(
second.WorldTime.DayFraction,
7.0 / 16.0 - 0.001,
7.0 / 16.0 + 0.001);
Assert.Equal(EnvironOverride.RedFog, first.Weather.Override);
Assert.Equal(EnvironOverride.None, second.Weather.Override);
Assert.Equal(WeatherKind.Clear, first.Weather.Kind);
Assert.Equal(WeatherKind.Overcast, second.Weather.Kind);
}
[Fact]
public void SynchronizeFromServer_ClearsDebugOverrideAndAdvancesClock()
{
var time = new ManualTimeProvider();
var state = new RuntimeWorldEnvironmentState(time);
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
_ = state.CycleTimeOfDay();
state.SynchronizeFromServer(0.0);
time.Advance(TimeSpan.FromSeconds(12));
Assert.Equal(12.0, state.WorldTime.NowTicks, 5);
Assert.NotEqual(0.0, state.WorldTime.DayFraction);
}
[Theory]
[InlineData(0x00u, EnvironOverride.None)]
[InlineData(0x01u, EnvironOverride.RedFog)]
[InlineData(0x06u, EnvironOverride.BlackFog2)]
public void ApplyAdminEnvirons_FogIsCanonicalRuntimeState(
uint raw,
EnvironOverride expected)
{
var state = Initialized();
RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(raw);
Assert.Equal(RuntimeEnvironmentEffectKind.FogOverride, effect.Kind);
Assert.Equal(expected, effect.FogOverride);
Assert.Equal(expected, state.Snapshot.EnvironOverride);
}
[Fact]
public void ApplyAdminEnvirons_SoundDoesNotMutateFogState()
{
var state = Initialized();
_ = state.ApplyAdminEnvirons(0x04u);
RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(0x78u);
Assert.Equal(RuntimeEnvironmentEffectKind.SoundCue, effect.Kind);
Assert.Equal(RuntimeEnvironmentSoundCue.Thunder3, effect.SoundCue);
Assert.Equal(EnvironOverride.GreenFog, state.Weather.Override);
}
[Fact]
public void ForcedDayGroup_IsDefinitionScoped()
{
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider());
state.Initialize(new RuntimeWorldEnvironmentDefinition(
DerethDateTime.DayFractionOriginOffsetTicks,
sourceTickSize: 0.8,
lightTickSize: 0.2,
[
Group("Sunny", Sky(0f)),
Group("Cloudy", Sky(0f)),
],
forcedDayGroupIndex: 1));
Assert.Equal(1, state.ActiveDayGroupIndex);
Assert.Equal(WeatherKind.Overcast, state.Weather.Kind);
}
[Fact]
public void Initialize_IsOneShot()
{
var state = Initialized();
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(
() => state.Initialize(Definition(
3600.0,
("Cloudy", Sky(0f)))));
Assert.Contains("one-shot", error.Message, StringComparison.Ordinal);
Assert.Equal(0, state.ActiveDayGroupIndex);
}
private static RuntimeWorldEnvironmentState Initialized()
{
var state = new RuntimeWorldEnvironmentState(
new ManualTimeProvider());
state.Initialize(Definition(
DerethDateTime.DayFractionOriginOffsetTicks,
("Sunny", Sky(0f))));
return state;
}
private static RuntimeWorldEnvironmentDefinition Definition(
double origin,
params (string Name, SkyStateProvider Sky)[] groups) =>
new(
origin,
sourceTickSize: 0.8,
lightTickSize: 0.2,
groups.Select(value => Group(value.Name, value.Sky)));
private static RuntimeWorldDayGroupDefinition Group(
string name,
SkyStateProvider sky) =>
new(
name,
ChanceOfOccur: 100f,
SkyObjectCount: 1,
sky);
private static SkyStateProvider Sky(float begin) =>
new(
[
new SkyKeyframe(
Begin: begin,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.Zero,
FogDensity: 0f),
]);
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now =
new(2026, 7, 26, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan elapsed) => _now += elapsed;
}
}