191 lines
9.5 KiB
C#
191 lines
9.5 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Server;
|
|
using ProjectM.Simulation;
|
|
using Unity.Collections;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// Pins Step 13 — <see cref="MetaSpendSystem"/>: the Staging-gated Aether→tier purchase with DR-014 in-loop
|
|
/// ledger atomicity (two same-tick barely-enough purchases → exactly ONE succeeds), the TotalOf pre-check
|
|
/// (Withdraw clamps, it never rejects), the ABSOLUTE-value modifier upsert on every live class member (R-F1/2),
|
|
/// tier bump-or-append on the director record, the SaveRequest flag, and the reject paths (wrong class mask,
|
|
/// MaxTier cap, non-Staging lifecycle) — with requests always consumed.
|
|
/// </summary>
|
|
public class MetaSpendSystemTests
|
|
{
|
|
static (World world, SimulationSystemGroup group) MakeWorld()
|
|
{
|
|
var world = new World("MetaSpendTest");
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<MetaSpendSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
return (world, group);
|
|
}
|
|
|
|
static Entity MakeDirector(EntityManager em, byte lifecycle, int aether)
|
|
{
|
|
var dir = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(SaveRequest),
|
|
typeof(MetaUpgradeCatalog));
|
|
em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle });
|
|
em.SetComponentData(dir, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() });
|
|
var ledger = em.AddBuffer<StorageEntry>(dir);
|
|
if (aether > 0) ledger.Add(new StorageEntry { ItemId = ResourceId.Aether, Count = aether });
|
|
em.AddBuffer<MetaTierState>(dir);
|
|
return dir;
|
|
}
|
|
|
|
static Entity MakePlayer(EntityManager em, int networkId, byte classId)
|
|
{
|
|
var conn = em.CreateEntity(typeof(NetworkId));
|
|
em.SetComponentData(conn, new NetworkId { Value = networkId });
|
|
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(PlayerClass));
|
|
em.SetComponentData(player, new GhostOwner { NetworkId = networkId });
|
|
em.SetComponentData(player, new PlayerClass { ClassId = classId });
|
|
em.AddBuffer<StatModifier>(player);
|
|
em.AddComponentData(player, new ConnRef { Conn = conn });
|
|
return player;
|
|
}
|
|
|
|
/// <summary>Test-only pointer so a request can be issued from the player's own connection.</summary>
|
|
struct ConnRef : IComponentData { public Entity Conn; }
|
|
|
|
static void SendRequest(EntityManager em, Entity player, byte upgradeId)
|
|
{
|
|
var conn = em.GetComponentData<ConnRef>(player).Conn;
|
|
var req = em.CreateEntity(typeof(MetaSpendRequest), typeof(ReceiveRpcCommandRequest));
|
|
em.SetComponentData(req, new MetaSpendRequest { UpgradeId = upgradeId });
|
|
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
|
|
}
|
|
|
|
static int PendingRequests(EntityManager em)
|
|
{
|
|
var q = em.CreateEntityQuery(typeof(MetaSpendRequest));
|
|
int n = q.CalculateEntityCount();
|
|
q.Dispose();
|
|
return n;
|
|
}
|
|
|
|
static float MetaModValue(EntityManager em, Entity player, byte upgradeId, out int rowCount)
|
|
{
|
|
var mods = em.GetBuffer<StatModifier>(player, true);
|
|
float value = 0f;
|
|
rowCount = 0;
|
|
for (int i = 0; i < mods.Length; i++)
|
|
if (mods[i].SourceId == Tuning.MetaSourceIdBase + upgradeId) { value = mods[i].Value; rowCount++; }
|
|
return value;
|
|
}
|
|
|
|
[Test]
|
|
public void Purchase_BumpsTier_Withdraws_UpsertsAllClassMembers_FlagsSave()
|
|
{
|
|
var (world, group) = MakeWorld();
|
|
var em = world.EntityManager;
|
|
var dir = MakeDirector(em, RunLifecycle.Staging, 25);
|
|
var warriorA = MakePlayer(em, 1, ClassTraits.WarriorClass);
|
|
var warriorB = MakePlayer(em, 2, ClassTraits.WarriorClass); // classmate: shared per-class pool
|
|
var ranger = MakePlayer(em, 3, ClassTraits.RangerClass); // other class: untouched
|
|
|
|
SendRequest(em, warriorA, 1); // Reinforced Frame: BaseCost 10, +15/tier
|
|
group.Update();
|
|
|
|
var record = em.GetBuffer<MetaTierState>(dir, true);
|
|
Assert.AreEqual(1, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier bumped to 1");
|
|
Assert.AreEqual(15, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
|
|
"cost 10 withdrawn from 25");
|
|
Assert.AreEqual(15f, MetaModValue(em, warriorA, 1, out int rowsA), 1e-3f, "buyer modifier = 15 * tier1");
|
|
Assert.AreEqual(1, rowsA);
|
|
Assert.AreEqual(15f, MetaModValue(em, warriorB, 1, out _), 1e-3f, "live classmate upserted too (R-F2)");
|
|
Assert.AreEqual(0f, MetaModValue(em, ranger, 1, out int rowsR), 1e-3f, "other class untouched");
|
|
Assert.AreEqual(0, rowsR);
|
|
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "purchase flags the autosave");
|
|
Assert.AreEqual(0, PendingRequests(em), "request consumed");
|
|
world.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public void TwoSameTick_BarelyEnough_ExactlyOneSucceeds()
|
|
{
|
|
var (world, group) = MakeWorld();
|
|
var em = world.EntityManager;
|
|
var dir = MakeDirector(em, RunLifecycle.Staging, 10); // ids 1 and 4 BOTH cost 10 at tier 0
|
|
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
|
|
|
|
SendRequest(em, warrior, 1);
|
|
SendRequest(em, warrior, 4);
|
|
group.Update();
|
|
|
|
var record = em.GetBuffer<MetaTierState>(dir, true);
|
|
int bought = MetaMath.TierOf(record, ClassTraits.WarriorClass, 1)
|
|
+ MetaMath.TierOf(record, ClassTraits.WarriorClass, 4);
|
|
Assert.AreEqual(1, bought, "in-loop atomicity: barely-enough Aether buys exactly ONE (DR-014)");
|
|
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
|
|
"the single cost fully drained the ledger — and never went negative (TotalOf pre-check)");
|
|
Assert.AreEqual(0, PendingRequests(em), "both requests consumed");
|
|
world.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public void SecondPurchase_AbsoluteUpsert_SingleRow_RampedCost()
|
|
{
|
|
var (world, group) = MakeWorld();
|
|
var em = world.EntityManager;
|
|
var dir = MakeDirector(em, RunLifecycle.Staging, 30); // tier1 = 10, tier2 = 10 + 1*5 = 15
|
|
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
|
|
|
|
SendRequest(em, warrior, 1);
|
|
group.Update();
|
|
SendRequest(em, warrior, 1);
|
|
group.Update();
|
|
|
|
var record = em.GetBuffer<MetaTierState>(dir, true);
|
|
Assert.AreEqual(2, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier 2 owned");
|
|
Assert.AreEqual(1, record.Length, "record row BUMPED in place, not duplicated");
|
|
Assert.AreEqual(30f, MetaModValue(em, warrior, 1, out int rows), 1e-3f,
|
|
"ABSOLUTE upsert: 15 * tier2 (R-F1)");
|
|
Assert.AreEqual(1, rows, "one modifier row — an incremental append would double-count in recompute");
|
|
Assert.AreEqual(5, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
|
|
"linear ramp: 30 - 10 - 15");
|
|
world.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public void Rejects_WrongClassMask_MaxTierCap_NonStaging()
|
|
{
|
|
var (world, group) = MakeWorld();
|
|
var em = world.EntityManager;
|
|
var dir = MakeDirector(em, RunLifecycle.Staging, 999);
|
|
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
|
|
|
|
// (a) Ranger-masked upgrade requested by a Warrior — dropped, nothing withdrawn.
|
|
SendRequest(em, warrior, 7); // Ranger's Longshot (mask 2)
|
|
group.Update();
|
|
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
|
|
"class-mask reject leaves the ledger untouched");
|
|
|
|
// (b) at MaxTier — dropped. Fleet Stride (id 4) MaxTier 3.
|
|
var record = em.GetBuffer<MetaTierState>(dir);
|
|
record.Add(new MetaTierState { ClassId = ClassTraits.WarriorClass, UpgradeId = 4, Tier = 3 });
|
|
SendRequest(em, warrior, 4);
|
|
group.Update();
|
|
Assert.AreEqual(3, MetaMath.TierOf(em.GetBuffer<MetaTierState>(dir, true), ClassTraits.WarriorClass, 4),
|
|
"MaxTier cap holds");
|
|
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether));
|
|
|
|
// (c) mid-run — dropped (N4: the shop is a between-runs surface).
|
|
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom });
|
|
SendRequest(em, warrior, 1);
|
|
group.Update();
|
|
Assert.AreEqual(0, MetaMath.TierOf(em.GetBuffer<MetaTierState>(dir, true), ClassTraits.WarriorClass, 1),
|
|
"non-Staging purchase dropped");
|
|
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether));
|
|
Assert.AreEqual(0, PendingRequests(em), "every request consumed, accepted or not");
|
|
world.Dispose();
|
|
}
|
|
}
|
|
}
|