Frontend menu + settings + saves foundation

Netcode frontend pattern: UITK main menu / pause / settings (MenuUi + controllers), on-demand world lifecycle (WorldLauncher/SessionRunner), GameBootstrap menu branch; Graphics/Audio settings (SettingsService/GameVolume); single-slot save foundation (SaveData/SaveService, born-correct load at director spawn, autosave on Siege->Calm + quit); RuntimePanelSettings + theme; BuildTool menu; 10 EditMode tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:05:36 -07:00
parent f3f65bccbf
commit f31ffe910b
56 changed files with 1744 additions and 8 deletions
@@ -0,0 +1,113 @@
using NUnit.Framework;
using ProjectM.Client;
using UnityEngine;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic tests for the client settings model (no ECS world): JSON round-trip via JsonUtility,
/// defensive clamping of hand-edited/corrupt values, and the GameVolume bus statics. Engine APIs
/// (Screen/QualitySettings) resolve in EditMode, so Defaults()/Clamped() are exercisable here.
/// </summary>
public class GameSettingsTests
{
static GameSettings Sample() => new GameSettings
{
Version = GameSettings.CurrentVersion,
ResWidth = 2560,
ResHeight = 1440,
RefreshHz = 144,
FullScreenMode = (int)FullScreenMode.ExclusiveFullScreen,
QualityLevel = 0,
VSync = 2,
TargetFps = 120,
Master = 0.8f,
Music = 0.5f,
Sfx = 0.3f,
};
[Test]
public void Json_RoundTrip_PreservesAllFields()
{
var src = Sample();
var json = JsonUtility.ToJson(src);
var dst = JsonUtility.FromJson<GameSettings>(json);
Assert.AreEqual(src.Version, dst.Version);
Assert.AreEqual(src.ResWidth, dst.ResWidth);
Assert.AreEqual(src.ResHeight, dst.ResHeight);
Assert.AreEqual(src.RefreshHz, dst.RefreshHz);
Assert.AreEqual(src.FullScreenMode, dst.FullScreenMode);
Assert.AreEqual(src.QualityLevel, dst.QualityLevel);
Assert.AreEqual(src.VSync, dst.VSync);
Assert.AreEqual(src.TargetFps, dst.TargetFps);
Assert.AreEqual(src.Master, dst.Master, 1e-5f);
Assert.AreEqual(src.Music, dst.Music, 1e-5f);
Assert.AreEqual(src.Sfx, dst.Sfx, 1e-5f);
}
[Test]
public void Clamped_BoundsOutOfRangeValues()
{
var bad = new GameSettings
{
ResWidth = 99999, ResHeight = -5, RefreshHz = -10,
FullScreenMode = 99, QualityLevel = 9999, VSync = 7,
TargetFps = 5, Master = 2f, Music = -1f, Sfx = 3f,
};
var c = bad.Clamped();
Assert.LessOrEqual(c.ResWidth, 7680);
Assert.GreaterOrEqual(c.ResHeight, 480);
Assert.GreaterOrEqual(c.RefreshHz, 0);
Assert.That(c.FullScreenMode, Is.InRange(0, 3));
Assert.That(c.VSync, Is.InRange(0, 2));
Assert.AreEqual(1f, c.Master, 1e-5f); // 2f -> 1
Assert.AreEqual(0f, c.Music, 1e-5f); // -1 -> 0
Assert.AreEqual(1f, c.Sfx, 1e-5f); // 3f -> 1
Assert.That(c.QualityLevel, Is.GreaterThanOrEqualTo(0)); // clamped to a valid level index
}
[Test]
public void Clamped_NonPositiveTargetFps_BecomesUncapped()
{
Assert.AreEqual(-1, new GameSettings { TargetFps = 0 }.Clamped().TargetFps);
Assert.AreEqual(-1, new GameSettings { TargetFps = -50 }.Clamped().TargetFps);
Assert.AreEqual(120, new GameSettings { TargetFps = 120, Master = 1f }.Clamped().TargetFps);
}
[Test]
public void Defaults_AreValidAndFullVolume()
{
var d = GameSettings.Defaults();
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
Assert.Greater(d.ResWidth, 0);
Assert.Greater(d.ResHeight, 0);
Assert.AreEqual(1f, d.Master, 1e-5f);
Assert.AreEqual(1f, d.Music, 1e-5f);
Assert.AreEqual(1f, d.Sfx, 1e-5f);
// Defaults survive a clamp unchanged (they are in-range by construction).
var c = d.Clamped();
Assert.AreEqual(d.Master, c.Master, 1e-5f);
Assert.AreEqual(d.VSync, c.VSync);
}
[Test]
public void GameVolume_BusesAreSettable_DefaultFull()
{
float prevMusic = GameVolume.Music, prevSfx = GameVolume.Sfx;
try
{
GameVolume.Music = 0.25f;
GameVolume.Sfx = 0.6f;
Assert.AreEqual(0.25f, GameVolume.Music, 1e-5f);
Assert.AreEqual(0.6f, GameVolume.Sfx, 1e-5f);
}
finally
{
GameVolume.Music = prevMusic;
GameVolume.Sfx = prevSfx;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5a097bb2e0683f544944c8a0cc9676ff
@@ -0,0 +1,107 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Tests
{
/// <summary>
/// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe,
/// and the born-correct ledger apply (<see cref="SaveApply.WriteLedger"/>) the server spawn system uses to
/// overwrite a director's StorageEntry buffer from a staged PendingSave.
/// </summary>
public class SavePersistenceTests
{
[Test]
public void SaveData_Json_RoundTrip_PreservesFields()
{
var data = new SaveData
{
GoalCharge = 42,
GoalTarget = 10,
Ledger = new[]
{
new LedgerRow { ItemId = 1, Count = 5 },
new LedgerRow { ItemId = 2, Count = 9 },
},
SavedAtMs = 1234567890123L,
};
var json = JsonUtility.ToJson(data);
var back = JsonUtility.FromJson<SaveData>(json);
Assert.AreEqual(SaveData.CurrentVersion, back.Version);
Assert.AreEqual(42, back.GoalCharge);
Assert.AreEqual(10, back.GoalTarget);
Assert.AreEqual(2, back.Ledger.Length);
Assert.AreEqual(1, back.Ledger[0].ItemId);
Assert.AreEqual(5, back.Ledger[0].Count);
Assert.AreEqual(2, back.Ledger[1].ItemId);
Assert.AreEqual(9, back.Ledger[1].Count);
Assert.AreEqual(1234567890123L, back.SavedAtMs);
}
[Test]
public void SaveData_EmptyJson_DoesNotThrow_And_EmptyLedgerRoundTrips()
{
Assert.DoesNotThrow(() => JsonUtility.FromJson<SaveData>("{}"));
var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(empty));
Assert.IsNotNull(back.Ledger);
Assert.AreEqual(0, back.Ledger.Length);
}
[Test]
public void SaveData_OldVersion_IsDetectable()
{
// A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game).
var old = new SaveData { Version = 0, GoalCharge = 7 };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(old));
Assert.AreEqual(0, back.Version);
Assert.AreNotEqual(SaveData.CurrentVersion, back.Version);
}
[Test]
public void WriteLedger_Overwrites_Destination_From_Staged_Rows()
{
using var world = new World("SaveApplyTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<PendingSaveLedgerRow>(e);
em.AddBuffer<StorageEntry>(e);
var src = em.GetBuffer<PendingSaveLedgerRow>(e);
src.Add(new PendingSaveLedgerRow { ItemId = 3, Count = 7 });
src.Add(new PendingSaveLedgerRow { ItemId = 5, Count = 12 });
var dest = em.GetBuffer<StorageEntry>(e);
dest.Add(new StorageEntry { ItemId = 99, Count = 1 }); // pre-existing junk that must be cleared
SaveApply.WriteLedger(em.GetBuffer<PendingSaveLedgerRow>(e), em.GetBuffer<StorageEntry>(e));
var result = em.GetBuffer<StorageEntry>(e);
Assert.AreEqual(2, result.Length);
Assert.AreEqual(3, result[0].ItemId);
Assert.AreEqual(7, result[0].Count);
Assert.AreEqual(5, result[1].ItemId);
Assert.AreEqual(12, result[1].Count);
}
[Test]
public void WriteLedger_EmptySource_ClearsDestination()
{
using var world = new World("SaveApplyEmptyTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<PendingSaveLedgerRow>(e);
em.AddBuffer<StorageEntry>(e);
var dest = em.GetBuffer<StorageEntry>(e);
dest.Add(new StorageEntry { ItemId = 1, Count = 1 });
SaveApply.WriteLedger(em.GetBuffer<PendingSaveLedgerRow>(e), em.GetBuffer<StorageEntry>(e));
Assert.AreEqual(0, em.GetBuffer<StorageEntry>(e).Length);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f637165d4b6be6940b81f5832204e89d