Files
kronic aac1813a93 Tests: END-2 win/lose + final-siege arming + SaveData v5 (342/342 EditMode)
EndgameWinLoseTests: arms-once+enter, Charge clamp, Victory/Loss edges, the END-1 soft-loss regression (normal overrun stays soft), restored-Victory-no-rearm, SiegeTimeout-not-culling-final, the full ThreatDirector->CyclePhase->GoalReached pipeline (arm-not-stomped-by-scheduler), and FinalSiegeMultiplier override + sub-1 floor. SavePersistenceTests: RunOutcome v5 round-trip + pre-v5 default-to-InProgress. TuningConfigTests: FinalSiegeMultiplier default pin. See DR-036.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:38:36 -07:00

189 lines
9.1 KiB
C#

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);
}
[Test]
public void StructureSave_HP_RoundTrips_And_Writes_V3()
{
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP).");
Assert.AreEqual(1, back.Structures.Length);
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
}
[Test]
public void V2_Save_IsWithinLoadableRange_And_ZeroHp_Restores_Full()
{
// A pre-EB-1 v2 save sits inside the additive load floor [Min,Current], so SaveService.Load accepts it;
// an unset HP (0) is mapped by BaseRestoreSystem to the baked Max (structures come back at full HP).
var v2 = new SaveData { Version = 2, GoalCharge = 3, GoalTarget = 10, Structures = new[] { new StructureSave { Type = 1, CellX = 2, CellZ = 4 } } };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(v2));
Assert.AreEqual(2, back.Version);
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v2 is at/above the load floor.");
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
Assert.AreEqual(0f, back.Structures[0].HP, 1e-4f, "unset HP (0) -> restore maps to baked Max.");
}
[Test]
public void ToPending_Maps_All_Fields_Including_The_Wounded_HP()
{
// The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught).
var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, Direction = 2, RemainingTicks = 50, ConveyorResId = 1, ConveyorCount = 4, HP = 37f };
var p = SaveApply.ToPending(s);
Assert.AreEqual(1, p.Type);
Assert.AreEqual(3, p.CellX);
Assert.AreEqual(-2, p.CellZ);
Assert.AreEqual(2, p.Direction);
Assert.AreEqual(50u, p.RemainingTicks);
Assert.AreEqual(1, p.ConveyorResId);
Assert.AreEqual(4, p.ConveyorCount);
Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy.");
}
[Test]
public void CoreCurrent_RoundTrips_And_Writes_Current_Version()
{
var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2).");
Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON.");
}
[Test]
public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero()
{
// A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the
// born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break.
var back = JsonUtility.FromJson<SaveData>("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}");
Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max.");
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor.");
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
}
[Test]
public void RunOutcome_RoundTrips_And_Writes_Current_Version()
{
var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-2: new saves write v5.");
Assert.AreEqual(5, SaveData.CurrentVersion, "SaveData is at v5 (END-2 added RunOutcome).");
Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON.");
}
[Test]
public void Pre_END2_Save_Missing_RunOutcome_Defaults_To_InProgress()
{
// A pre-END-2 (v4) save JSON lacks RunOutcome -> JsonUtility defaults it to 0 (InProgress) -> the run loads
// as in-progress, NOT a finished run. Additive: no field, no break; v4 stays within the load floor.
var back = JsonUtility.FromJson<SaveData>("{\"Version\":4,\"GoalCharge\":2,\"GoalTarget\":10,\"CoreCurrent\":50}");
Assert.AreEqual((int)RunOutcomeId.InProgress, back.RunOutcome, "missing RunOutcome -> 0 (InProgress).");
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v4 stays within the additive load floor.");
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
}
}
}