using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
///
/// Pins the two-channel boon lifecycle: (a valid pick appends exactly ONE
/// boon-band and clears Pending; out-of-range / not-pending / closed-lifecycle picks
/// are rejected; the grace auto-pick deals Option0) and the RunDirector Returning-edge RANGE STRIP (every
/// boon-band row dies; class/meta/equip bands survive; offers zeroed) — run boons NEVER persist (DR-037).
///
public class BoonApplyTests
{
const uint T0 = 3000;
static (World world, SimulationSystemGroup group, Entity dir, Entity catalog) MakeWorld(byte lifecycle)
{
var world = new World("BoonApplyTest");
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime));
em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = 1 });
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RoomEpoch = 2, RewardGraceTick = T0 + 1800 });
var catalog = em.CreateEntity(typeof(BoonCatalog));
em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() });
return (world, group, dir, catalog);
}
static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag));
em.AddBuffer(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(e, new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 });
return e;
}
static void SendPick(EntityManager em, int netId, byte index)
{
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = netId });
var req = em.CreateEntity(typeof(BoonPickRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(req, new BoonPickRequest { Index = index });
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int BoonRows(EntityManager em, Entity player)
{
var mods = em.GetBuffer(player);
int n = 0;
for (int i = 0; i < mods.Length; i++)
if (mods[i].SourceId >= Tuning.BoonSourceIdBase
&& mods[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan) n++;
return n;
}
[Test]
public void ValidPick_AppendsBoonBandRow_AndClearsPending()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
var em = world.EntityManager;
var player = MakePicker(em, 1);
SendPick(em, 1, index: 1); // Option1 = id 4 (Fleet Foot, MoveSpeed +12%)
group.Update();
Assert.AreEqual(1, BoonRows(em, player), "exactly one boon-band row appended");
var mods = em.GetBuffer(player);
Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target");
Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op);
Assert.AreEqual(0.12f, mods[0].Value, 1e-4f);
Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed");
Assert.AreEqual(1u, em.GetComponentData(dir).BoonPickCounter, "band provenance advanced");
world.Dispose();
}
[Test]
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
{
// Not pending.
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
var p1 = MakePicker(w1.EntityManager, 1);
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 1 });
SendPick(w1.EntityManager, 1, 0);
g1.Update();
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
w1.Dispose();
// Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4).
var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning);
var p2 = MakePicker(w2.EntityManager, 1);
SendPick(w2.EntityManager, 1, 0);
g2.Update();
Assert.AreEqual(0, BoonRows(w2.EntityManager, p2), "closed-lifecycle pick rejected");
using (var q = w2.EntityManager.CreateEntityQuery(typeof(BoonPickRequest)))
Assert.AreEqual(0, q.CalculateEntityCount(), "request still consumed");
w2.Dispose();
}
[Test]
public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
var em = world.EntityManager;
var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth)
var run = em.GetComponentData(dir);
run.RewardGraceTick = T0 - 10; // already elapsed
em.SetComponentData(dir, run);
group.Update();
Assert.AreEqual(1, BoonRows(em, afk), "AFK player auto-dealt Option0");
var mods = em.GetBuffer(afk);
Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
Assert.AreEqual(0, em.GetComponentData(afk).Pending, "gate released");
world.Dispose();
}
[Test]
public void ReturningStrip_KillsBoonBand_SparesClassMetaEquip()
{
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four bands.
var world = new World("BoonStripTest");
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand));
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Returning, CurrentRoom = 3, RoomCount = 8 });
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer),
typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
var mods = em.AddBuffer(player);
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.5f, SourceId = Tuning.BoonSourceIdBase + 1 }); // boon
mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class
mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band)
mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip
group.Update(); // Returning: strip + bank + home -> Staging
var after = em.GetBuffer(player);
Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive");
for (int i = 0; i < after.Length; i++)
Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase
&& after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor");
Assert.AreEqual(0, em.GetComponentData(player).Pending, "straggler offer zeroed");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle);
world.Dispose();
}
}
}