using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — the authoritative /// death→respawn timer. A bare world is seeded with NetworkTime + PlayerSpawner singletons and a player /// (Health, RespawnState, RespawnInvuln, LocalTransform, GhostOwner, EffectiveCharacterStats, PlayerTag). /// Pins: a newly-dead player schedules a respawn tick; a due tick refills health + repositions + grants /// invuln + clears the schedule; an alive player clears any stale pending schedule. /// public class PlayerRespawnSystemTests { static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); 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(serverTick) }); var sp = em.CreateEntity(typeof(PlayerSpawner)); em.SetComponentData(sp, new PlayerSpawner { SpawnPoint = new float3(0, 1, 0), SpawnRingRadius = 10f, RingSlots = 4 }); return (world, group); } static Entity MakePlayer(EntityManager em, float health, float maxHealth, uint respawnTick, int delayTicks, int invulnTicks, float3 pos, int networkId) { var e = em.CreateEntity(); em.AddComponentData(e, new Health { Current = health, Max = maxHealth }); em.AddComponentData(e, new RespawnState { RespawnTick = respawnTick, DelayTicks = delayTicks, InvulnTicks = invulnTicks }); em.AddComponentData(e, new RespawnInvuln { UntilTick = 0 }); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth }); em.AddComponent(e); return e; } [Test] public void Newly_Dead_Player_Schedules_Respawn_Tick() { var (world, group) = MakeWorld("RespawnSchedule", serverTick: 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 0, delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1); group.Update(); Assert.AreEqual(RespawnMath.RespawnTick(200, 60), em.GetComponentData(player).RespawnTick, "A newly-dead player schedules its respawn tick (now + delay)."); Assert.AreEqual(0f, em.GetComponentData(player).Current, 1e-4f, "Still down until the tick is due."); } } [Test] public void Due_Respawn_Restores_Health_Repositions_And_Grants_Invuln() { var (world, group) = MakeWorld("RespawnRecover", serverTick: 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160, delayTicks: 60, invulnTicks: 120, pos: new float3(999, 1, 999), networkId: 1); group.Update(); Assert.AreEqual(100f, em.GetComponentData(player).Current, 1e-4f, "Health refills to the effective max."); Assert.AreEqual(320u, em.GetComponentData(player).UntilTick, "Post-respawn invulnerability is granted until now + InvulnTicks (200 + 120)."); Assert.AreEqual(0u, em.GetComponentData(player).RespawnTick, "The respawn schedule is cleared on recovery."); Assert.Less(em.GetComponentData(player).Position.x, 100f, "The player is teleported from its death spot back to the base spawn ring."); } } [Test] public void Alive_Player_Clears_Stale_Pending_Respawn() { var (world, group) = MakeWorld("RespawnAliveClear", serverTick: 200); using (world) { var em = world.EntityManager; var player = MakePlayer(em, health: 50f, maxHealth: 100f, respawnTick: 999, delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1); group.Update(); Assert.AreEqual(0u, em.GetComponentData(player).RespawnTick, "An alive player clears any stale pending respawn schedule."); Assert.AreEqual(50f, em.GetComponentData(player).Current, 1e-4f, "Alive health is untouched."); } } } }