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>
201 lines
7 KiB
C#
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
|
|
}
|
|
}
|
|
}
|