Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -11,10 +11,13 @@ using System.Collections.Generic;
namespace ProjectM.Tests
{
/// <summary>
/// Pins the two-channel boon lifecycle: <see cref="BoonApplySystem"/> (a valid pick appends exactly ONE
/// boon-band <see cref="StatModifier"/> 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).
/// Pins the two-channel boon lifecycle (Phase 1.7 table). <see cref="BoonApplySystem"/>: a valid STAT pick appends
/// exactly ONE boon-band <see cref="StatModifier"/> and clears Pending; a MECHANIC-CHANGER pick mutates
/// <see cref="BoonEffects"/> (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected;
/// the grace auto-pick deals Option0. The RunDirector Returning-edge strip: every boon-band StatModifier dies,
/// BoonEffects is zeroed, the Frenzy timed row is removed from BOTH buffers, and class/meta/equip bands survive.
/// New default table ids: 1 Piercing (effect), 4 Detonating (effect), 9 Executioner (Damage +50%),
/// 10 Titan (MaxHealth +60), 11 Fleet Foot (MoveSpeed +18%).
/// </summary>
public class BoonApplyTests
{
@@ -48,9 +51,11 @@ namespace ProjectM.Tests
return (world, group, dir, catalog);
}
static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5)
// Defaults to STAT ids so an accepted pick appends a StatModifier row (o1 = 11 Fleet Foot).
static Entity MakePicker(EntityManager em, int netId, byte o0 = 9, byte o1 = 11, byte o2 = 10)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag));
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag),
typeof(BoonEffects));
em.AddBuffer<StatModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
@@ -78,14 +83,14 @@ namespace ProjectM.Tests
}
[Test]
public void ValidPick_AppendsBoonBandRow_AndClearsPending()
public void ValidStatPick_AppendsBoonBandRow_AndClearsPending()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var player = MakePicker(em, 1);
SendPick(em, 1, index: 1); // Option1 = id 4 (Fleet Foot, MoveSpeed +12%)
SendPick(em, 1, index: 1); // Option1 = id 11 (Fleet Foot, MoveSpeed +18%)
group.Update();
@@ -93,12 +98,31 @@ namespace ProjectM.Tests
var mods = em.GetBuffer<StatModifier>(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.18f, mods[0].Value, 1e-4f);
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced");
}
}
[Test]
public void EffectPick_MutatesBoonEffects_AppendsNoStatRow()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var player = MakePicker(em, 1, o0: 1); // Option0 = id 1 (Piercing Shots — a mechanic-changer)
SendPick(em, 1, index: 0);
group.Update();
Assert.AreEqual(0, BoonRows(em, player), "a mechanic-changer appends NO StatModifier row");
Assert.AreEqual(1, em.GetComponentData<BoonEffects>(player).Pierce, "Pierce incremented");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(0u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "no band row → counter unchanged");
}
}
[Test]
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
{
@@ -106,7 +130,7 @@ namespace ProjectM.Tests
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
_worlds.Add(w1);
var p1 = MakePicker(w1.EntityManager, 1);
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 1 });
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 9 });
SendPick(w1.EntityManager, 1, 0);
g1.Update();
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
@@ -129,7 +153,7 @@ namespace ProjectM.Tests
using (world)
{
var em = world.EntityManager;
var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth)
var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 MaxHealth)
var run = em.GetComponentData<RunRuntime>(dir);
run.RewardGraceTick = T0 - 10; // already elapsed
em.SetComponentData(dir, run);
@@ -144,9 +168,10 @@ namespace ProjectM.Tests
}
[Test]
public void ReturningStrip_KillsBoonBand_SparesClassMetaEquip()
public void ReturningStrip_KillsBoonBand_ZeroesEffects_SparesClassMetaEquip()
{
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four bands.
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four StatModifier bands
// PLUS mechanic-changer BoonEffects + a Frenzy timed row (StatModifier + TimedModifier).
var world = new World("BoonStripTest");
using (world)
{
@@ -163,24 +188,29 @@ namespace ProjectM.Tests
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));
typeof(RegionTag), typeof(LocalTransform), typeof(BoonEffects));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy });
var mods = em.AddBuffer<StatModifier>(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 = 1, Op = 2, Value = -0.3f, SourceId = Tuning.FrenzySourceId }); // Frenzy (boon band top)
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
var timed = em.AddBuffer<TimedModifier>(player);
timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 });
group.Update(); // Returning: strip + bank + home -> Staging
var after = em.GetBuffer<StatModifier>(player);
Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive");
Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, 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.GetBuffer<TimedModifier>(player).Length, "Frenzy timed row stripped");
Assert.AreEqual(default(BoonEffects), em.GetComponentData<BoonEffects>(player), "mechanic-changer effects zeroed");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
}
@@ -25,14 +25,14 @@ namespace ProjectM.Tests
{
for (uint seed = 1; seed < 200; seed += 7)
{
int n = BoonMath.PickBoons(seed, classId, ref pool, out byte a0, out byte a1, out byte a2);
int n = BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
Assert.AreEqual(3, n, "the default pool always fills 3 options");
Assert.AreNotEqual(a0, a1, "distinct");
Assert.AreNotEqual(a1, a2, "distinct");
Assert.AreNotEqual(a0, a2, "distinct");
// Deterministic re-draw.
BoonMath.PickBoons(seed, classId, ref pool, out byte b0, out byte b1, out byte b2);
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte b0, out byte b1, out byte b2);
Assert.AreEqual(a0, b0);
Assert.AreEqual(a1, b1);
Assert.AreEqual(a2, b2);
@@ -73,7 +73,7 @@ namespace ProjectM.Tests
Entity MakePlayer(int netId, byte region, byte classId)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner),
typeof(RegionTag), typeof(PlayerClass));
typeof(RegionTag), typeof(PlayerClass), typeof(BoonEffects));
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, new PlayerClass { ClassId = classId });
@@ -102,5 +102,44 @@ namespace ProjectM.Tests
}
}
}
static byte FamilyOf(ref BoonCatalogBlob pool, byte id)
{
int idx = BoonMath.FindDef(ref pool, id);
return idx >= 0 ? pool.Defs[idx].Family : (byte)0;
}
[Test]
public void PickBoons_NeverOffersTwoSameFamily_InOneDeal()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
for (byte classId = 0; classId <= 1; classId++)
for (uint seed = 1; seed < 200; seed += 3)
{
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
byte f0 = FamilyOf(ref pool, a0), f1 = FamilyOf(ref pool, a1), f2 = FamilyOf(ref pool, a2);
Assert.AreNotEqual(f0, f1, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f1, f2, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f0, f2, "dominated-offer protection: no two same-family options in one deal");
}
blob.Dispose();
}
[Test]
public void PickBoons_ExcludesOwnedNonStackingFlag()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
var owned = new BoonEffects { Flags = BoonFlag.DashTrail }; // already own Blade Dash (id 5, both classes)
for (byte classId = 0; classId <= 1; classId++)
for (uint seed = 1; seed < 300; seed += 3)
{
BoonMath.PickBoons(seed, classId, owned, ref pool, out byte a0, out byte a1, out byte a2);
Assert.IsFalse(a0 == 5 || a1 == 5 || a2 == 5, "an owned non-stacking flag boon (Blade Dash) is never re-offered");
}
blob.Dispose();
}
}
}
@@ -0,0 +1,94 @@
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
{
/// <summary>
/// Plain-Entities tests for <see cref="DashTrailDamageSystem"/> (Phase 1.7 Blade Dash). A dashing player with the
/// boon damages a nearby enemy ONCE per dash (StartTick-keyed dedup survives a re-tick); a fresh dash hits again;
/// no boon → no damage.
/// </summary>
public class DashTrailDamageSystemTests
{
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick)
{
var world = new World("DashTrailTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashTrailDamageSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
em.SetComponentData(em.CreateEntity(typeof(NetworkTime)), new NetworkTime { ServerTick = new NetworkTick(tick) });
return (world, group, em);
}
static void SetTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakeDasher(EntityManager em, byte flags, uint startTick, uint iframeUntil)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects), typeof(DashTrailState));
em.AddComponentData(e, LocalTransform.FromPosition(new float3(0f, 0f, 0f)));
em.AddComponentData(e, new DashState { Dir = new float2(1f, 0f), StartTick = startTick, IFrameUntilTick = iframeUntil, RecoverUntilTick = iframeUntil + 9 });
em.AddComponent<Simulate>(e); // enabled by default
em.SetComponentData(e, new GhostOwner { NetworkId = 1 });
em.SetComponentData(e, new BoonEffects { Flags = flags });
return e;
}
static Entity MakeEnemy(EntityManager em, float3 pos)
{
var e = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = 0.5f });
em.AddComponentData(e, new Health { Current = 60f, Max = 60f });
em.AddBuffer<DamageEvent>(e);
return e;
}
[Test]
public void BladeDash_DamagesNearbyEnemy_OncePerDash_ReHitsOnNextDash()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
var player = MakeDasher(em, BoonFlag.DashTrail, startTick: 100, iframeUntil: 112);
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); // within 1.6 + 0.5
group.Update(); // tick 100, dashing
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "enemy in the dash path takes one hit");
group.Update(); // same tick + same StartTick -> dedup, no second hit
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "no re-hit within the same dash");
// A fresh dash (new StartTick) resets the dedup set -> the enemy can be hit again.
SetTick(em, 130);
em.SetComponentData(player, new DashState { Dir = new float2(1f, 0f), StartTick = 130, IFrameUntilTick = 142, RecoverUntilTick = 151 });
group.Update();
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length, "a fresh dash hits the enemy again");
}
}
[Test]
public void NoBoon_NoDamage()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
MakeDasher(em, flags: 0, startTick: 100, iframeUntil: 112); // no DashTrail flag
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no Blade Dash boon -> no trail damage");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7603c5c6b91bb854d8b88739bdb6f4b1
@@ -0,0 +1,128 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities tests for <see cref="KillRewardSystem"/> (Phase 1.7 on-kill boons). Siphon heals the credited
/// killer (clamped to their effective max, once per corpse via the Dying.Rewarded latch); Frenzy upserts a single
/// cooldown-reduction row; an unresolved killer (KillerNetId &lt; 0) grants nothing but is still latched.
/// </summary>
public class KillRewardSystemTests
{
const uint T0 = 5000;
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
{
var world = new World("KillRewardTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<KillRewardSystem>());
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) });
return (world, group, em);
}
static Entity MakeKiller(EntityManager em, int netId, byte flags, float hp, float maxHp)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects),
typeof(Health), typeof(EffectiveCharacterStats));
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new BoonEffects { Flags = flags });
em.SetComponentData(e, new Health { Current = hp, Max = maxHp });
em.SetComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHp });
return e;
}
static Entity MakeCorpse(EntityManager em, int killerNetId)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(e, new Dying { UntilTick = T0 + 50, KillerNetId = killerNetId, Rewarded = 0 });
return e;
}
static int FrenzyRows(EntityManager em, Entity player)
{
var mods = em.GetBuffer<StatModifier>(player);
int n = 0;
for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == Tuning.FrenzySourceId) n++;
return n;
}
[Test]
public void Siphon_HealsKiller_ClampedToMax_OncePerCorpse()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.Greater(em.GetComponentData<Health>(killer).Current, 50f, "Siphon healed the killer");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "corpse latched as rewarded");
float afterFirst = em.GetComponentData<Health>(killer).Current;
group.Update(); // second tick: Rewarded==1 -> no double-heal
Assert.AreEqual(afterFirst, em.GetComponentData<Health>(killer).Current, 1e-4f, "no double-heal on a re-tick");
}
}
[Test]
public void Siphon_DoesNotOverheal_AboveEffectiveMax()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 128f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(130f, em.GetComponentData<Health>(killer).Current, 1e-4f, "heal clamps to the effective max");
}
}
[Test]
public void Frenzy_UpsertsSingleCooldownRow()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Frenzy, hp: 100f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "one Frenzy StatModifier row");
// A second corpse (new kill) re-stamps rather than stacking.
var c2 = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(c2, new Dying { UntilTick = T0 + 60, KillerNetId = 1, Rewarded = 0 });
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "Frenzy refreshes, never stacks");
}
}
[Test]
public void UnresolvedKiller_GrantsNothing_ButLatches()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon | BoonFlag.Frenzy, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: -1); // environment/AoE kill — no credit
group.Update();
Assert.AreEqual(50f, em.GetComponentData<Health>(killer).Current, 1e-4f, "no heal for an uncredited kill");
Assert.AreEqual(0, FrenzyRows(em, killer), "no Frenzy for an uncredited kill");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "still latched so it is not reprocessed");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e10a4fa71c531a42b093a1b43d1ccaf
@@ -147,5 +147,65 @@ namespace ProjectM.Tests
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage.");
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
}
}
static Entity MakeProjectileFx(EntityManager em, float3 pos, float2 dir, float speed, float damage,
float range, float distanceTravelled, int ownerId, byte pierce, byte chain, byte flags = 0)
{
var e = MakeProjectile(em, pos, dir, speed, damage, range, distanceTravelled, ownerId);
em.AddComponentData(e, new ProjectileEffectState { PierceRemaining = pierce, ChainRemaining = chain, Flags = flags });
return e;
}
[Test]
public void Pierce_SurvivesFirstTarget_HitsSecond_NeverReHitsFirst()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f);
var b = MakeTarget(em, new float3(0f, 0f, 6f), hitRadius: 0.8f, health: 60f);
// Post-move at z=6; the swept segment [z=0 -> z=6] (speed*dt = 6) covers both; A (z=3) is earliest.
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 1, chain: 0);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "earliest target A is hit");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "only the earliest target is hit per tick");
Assert.IsTrue(em.Exists(proj), "pierce lets the projectile survive the first hit");
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).PierceRemaining, "pierce consumed");
Tick(world, group, 0.1f); // same position: A now excluded by the hit-set -> B is the earliest
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A must NOT be re-hit (hit-set exclusion)");
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(b).Length, "B hit on the second pass");
Assert.IsFalse(em.Exists(proj), "with pierce spent, the second hit consumes the projectile");
}
[Test]
public void Chain_RetargetsTowardNextEnemy_AfterHit()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); // on-axis, hit first
var b = MakeTarget(em, new float3(2f, 0f, 6f), hitRadius: 0.8f, health: 60f); // off-axis, chain target
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 0, chain: 1);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A (on the path) is hit");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "B is off the path, not hit by the sweep this tick");
Assert.IsTrue(em.Exists(proj), "chain lets the projectile survive to seek the next enemy");
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).ChainRemaining, "chain consumed");
var dir = em.GetComponentData<Projectile>(proj).Direction;
Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)");
}
}
}
@@ -38,5 +38,26 @@ namespace ProjectM.Tests
Assert.DoesNotThrow(() => group.SortSystems(),
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
}
}
[Test]
public void PredictedCombatChain_Sorts_Without_A_Dependency_Cycle()
{
// Phase 1.7 added DashTrailDamageSystem ([UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)])
// and KillRewardSystem ([UpdateAfter(HealthApplyDamageSystem)]) to the predicted combat chain. A cycle in
// these [UpdateBefore/After] edges is INVISIBLE to per-system fixtures — it only throws at Play world
// creation. Co-register the chain and sort to reproduce that headlessly (SortSystems only, never Update).
using var world = new World("OrderCyclePredicted");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
void Add<T>() where T : unmanaged, ISystem
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>(); Add<DashTrailDamageSystem>();
Add<AbilityFireSystem>(); Add<ProjectileMoveSystem>(); Add<ProjectileDamageSystem>();
Add<HealthApplyDamageSystem>(); Add<KillRewardSystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A cycle in the Phase 1.7 predicted combat chain throws here instead of only at Play world-creation.");
}
}
}
@@ -0,0 +1,62 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Pins <see cref="TimedModifierUtil.Upsert"/> (Phase 1.7 C4): a repeat grant on one SourceId REFRESHES (re-stamps
/// UntilTick) rather than STACKING — exactly one row per id in BOTH the StatModifier and TimedModifier buffers —
/// and the TimedModifier-buffer <see cref="TimedModifierUtil.RemoveBySourceId(DynamicBuffer{TimedModifier}, uint)"/>
/// overload (C5) clears the paired timed row.
/// </summary>
public class TimedModifierUtilTests
{
static (int stat, int timed, uint until) Count(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> timed, uint id)
{
int s = 0; for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == id) s++;
int t = 0; uint u = 0; for (int i = 0; i < timed.Length; i++) if (timed[i].SourceId == id) { t++; u = timed[i].UntilTick; }
return (s, t, u);
}
[Test]
public void Upsert_RefreshesExactlyOneRow_InBothBuffers()
{
using var world = new World("UpsertTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
uint id = Tuning.FrenzySourceId;
for (uint k = 1; k <= 3; k++)
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 100u * k);
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
Assert.AreEqual(1, c.stat, "exactly one StatModifier row (refresh, never stack)");
Assert.AreEqual(1, c.timed, "exactly one TimedModifier row");
Assert.AreEqual(300u, c.until, "UntilTick re-stamped to the latest grant");
}
[Test]
public void RemoveBySourceId_TimedOverload_ClearsPairedRow()
{
using var world = new World("TimedStripTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
uint id = Tuning.FrenzySourceId;
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 500u);
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<StatModifier>(e), id);
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<TimedModifier>(e), id);
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
Assert.AreEqual(0, c.stat, "StatModifier row stripped");
Assert.AreEqual(0, c.timed, "TimedModifier row stripped");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a4b41bc944a5f4340ad5eef53beb2cfc