acdream/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

201 lines
7 KiB
C#

using System;
using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class WeatherSystemTests
{
[Fact]
public void Roll_Deterministic_ForSameDayIndex()
{
var a = new WeatherSystem();
var b = new WeatherSystem();
for (int d = 0; d < 100; d++)
{
a.Tick(0, d, 100f); // big dt to finish any transition
b.Tick(0, d, 100f);
Assert.Equal(a.Kind, b.Kind);
}
}
[Fact]
public void Roll_WeightsDominatedByClear()
{
// Clear should cover ~60% of the distribution. Sample many days
// and check the clear fraction is in a reasonable band.
var sys = new WeatherSystem();
int clear = 0;
for (int d = 0; d < 1000; d++)
{
sys.Tick(0, d, 100f);
if (sys.Kind == WeatherKind.Clear) clear++;
}
double frac = clear / 1000.0;
Assert.InRange(frac, 0.45, 0.75);
}
[Fact]
public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind()
{
// Phase 7: retail DOES NOT override fog by WeatherKind — Storm
// doesn't produce denser fog, Overcast doesn't shrink distance.
// Every Kind renders the keyframe's fog directly. This test
// replaces the old "Transition_EasesAcrossTenSeconds" which
// codified the invented per-Kind fog behaviour.
var kf = SkyStateProvider.Default().Interpolate(0.5f);
foreach (var kind in new[] {
WeatherKind.Clear, WeatherKind.Overcast,
WeatherKind.Rain, WeatherKind.Snow, WeatherKind.Storm,
})
{
var sys = new WeatherSystem();
sys.ForceWeather(kind);
sys.Tick(0, 1, 100f); // finalize any transition
var snap = sys.Snapshot(in kf);
Assert.Equal(kind, snap.Kind);
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
Assert.Equal(kf.FogColor, snap.FogColor);
}
}
// ── OP4 (Campaign OP, 2026-08-11): DisableDistanceFogSource —
// PlayerOption DisableDistanceFog -> LScape::m_fFogEnabled = !value.
[Fact]
public void DisableDistanceFogSource_Unbound_LeavesKeyframeFogModeUnchanged()
{
var sys = new WeatherSystem();
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(kf.FogMode, snap.FogMode);
}
[Fact]
public void DisableDistanceFogSource_True_ForcesFogModeOff()
{
var sys = new WeatherSystem { DisableDistanceFogSource = () => true };
var kf = SkyStateProvider.Default().Interpolate(0.5f);
Assert.NotEqual(FogMode.Off, kf.FogMode); // sanity: the keyframe itself authors real fog
var snap = sys.Snapshot(in kf);
Assert.Equal(FogMode.Off, snap.FogMode);
// Distances are left alone (the shader never reads them once off).
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
}
[Fact]
public void DisableDistanceFogSource_False_LeavesFogModeAlone()
{
var sys = new WeatherSystem { DisableDistanceFogSource = () => false };
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(kf.FogMode, snap.FogMode);
}
[Fact]
public void EnvironOverride_ForcesTintedFog()
{
var sys = new WeatherSystem();
sys.Override = EnvironOverride.RedFog;
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(EnvironOverride.RedFog, snap.Override);
// Red override means the R channel dominates.
Assert.True(snap.FogColor.X > snap.FogColor.Y);
Assert.True(snap.FogColor.X > snap.FogColor.Z);
}
[Fact]
public void Flash_DecaysOverTime()
{
var sys = new WeatherSystem();
sys.TriggerFlash();
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var imm = sys.Snapshot(in kf);
Assert.True(imm.LightningFlash > 0.9f);
// After 1 second the flash should be mostly decayed.
sys.Tick(0, 0, 1.0f);
var later = sys.Snapshot(in kf);
Assert.True(later.LightningFlash < 0.1f,
$"lightning flash didn't decay: {later.LightningFlash}");
}
[Fact]
public void Snapshot_ClearKind_PassesThroughKeyframeFog()
{
var sys = new WeatherSystem();
sys.ForceWeather(WeatherKind.Clear);
sys.Tick(0, 0, 100f); // finish transition
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
// Clear passes the keyframe's fog color + distances through.
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
}
[Theory]
[InlineData("Sunny", WeatherKind.Clear)]
[InlineData("SUNNY", WeatherKind.Clear)]
[InlineData("Clear", WeatherKind.Clear)]
[InlineData("", WeatherKind.Clear)]
[InlineData(null, WeatherKind.Clear)]
// All "weathery" names map to Overcast. Retail does NOT spawn rain /
// snow / lightning from the DayGroup name — verified by the 2026-04-23
// PhysicsScript + sky-PES decompile audits (see WeatherState.cs). Any
// future particle rain must come from the camera-attached weather
// subsystem, NOT from name string matching.
[InlineData("Cloudy", WeatherKind.Overcast)]
[InlineData("Overcast", WeatherKind.Overcast)]
[InlineData("Dark skies", WeatherKind.Overcast)]
[InlineData("Fog", WeatherKind.Overcast)]
[InlineData("Rainy", WeatherKind.Overcast)]
[InlineData("heavy rain", WeatherKind.Overcast)]
[InlineData("Snowy", WeatherKind.Overcast)]
[InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default
[InlineData("Stormy", WeatherKind.Overcast)]
[InlineData("Thunderstorm", WeatherKind.Overcast)]
public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected)
{
var sys = new WeatherSystem();
sys.SetKindFromDayGroupName(name);
sys.Tick(0, 0, 100f); // finalize transition
Assert.Equal(expected, sys.Kind);
}
[Fact]
public void SetKindFromDayGroupName_DisablesInternalRoll()
{
// Once driven externally, advancing dayIndex must NOT re-roll
// to a different kind via the internal RollKind hash.
var sys = new WeatherSystem();
sys.SetKindFromDayGroupName("Sunny");
sys.Tick(0, 0, 100f);
var clearKind = sys.Kind;
Assert.Equal(WeatherKind.Clear, clearKind);
for (int d = 1; d < 50; d++)
{
sys.Tick(0, d, 100f);
Assert.Equal(clearKind, sys.Kind); // stays put — no auto-roll
}
}
}