Attack Boon Changes
This commit is contained in:
@@ -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 < 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
|
||||
Reference in New Issue
Block a user