diff --git a/Assets/_Project/Scripts/Authoring/Combat/ProjectileAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ProjectileAuthoring.cs index 59dccf168..f5ee084ff 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/ProjectileAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/ProjectileAuthoring.cs @@ -32,6 +32,9 @@ namespace ProjectM.Authoring Damage = authoring.Damage, Range = authoring.Range }); + // Phase 1.7: server-only pierce/chain/pull state, baked inert; seeded at spawn by AbilityFireSystem + // (a separate component keeps the Projectile ghost hash frozen). + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index d9095496b..845548071 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -105,6 +105,10 @@ namespace ProjectM.Authoring // flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up). AddComponent(entity); AddComponent(entity); + // Phase 1.7 boon overhaul: the mechanic-changer state (replicated SendToOwner, baked inert, zeroed on + // the Returning edge) + the server-only Blade-Dash per-dash dedup accumulator (non-replicated). + AddComponent(entity); + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs index adaba71d6..ca378633d 100644 --- a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs @@ -118,6 +118,30 @@ namespace ProjectM.Server if (idx < 0) return false; // unknown id (catalog drift) — preserve-and-skip, never throw + if (pool.Defs[idx].Kind == 1) + { + // Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of + // appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row). + if (!state.EntityManager.HasComponent(player)) + return false; // real players are baked with it; skip defensively otherwise + var fx = state.EntityManager.GetComponentData(player); + byte delta = (byte)pool.Defs[idx].Value; + switch (pool.Defs[idx].EffectKind) + { + case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break; + case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break; + case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break; + case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break; + case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break; + case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break; + case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break; + case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break; + default: return false; // unknown effect kind — preserve-and-skip + } + state.EntityManager.SetComponentData(player, fx); + return true; + } + var mods = state.EntityManager.GetBuffer(player); mods.Add(new StatModifier { diff --git a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs index e5c56791b..699c7426b 100644 --- a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs @@ -58,16 +58,16 @@ namespace ProjectM.Server return; ref var pool = ref catalog.Value.Value; - foreach (var (offer, owner, region, cls) in - SystemAPI.Query, RefRO, RefRO, RefRO>() + foreach (var (offer, owner, region, cls, fx) in + SystemAPI.Query, RefRO, RefRO, RefRO, RefRO>() .WithAll()) { if (region.ValueRO.Region != RegionId.Expedition) continue; // home-bound players (dead-respawned, joiners) are dealt nothing - // Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room). + // Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw). uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u; - BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2); + BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2); offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 }; } diff --git a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs new file mode 100644 index 000000000..2fe0f39ed --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs @@ -0,0 +1,139 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// Phase 1.7 "Blade Dash" boon (): while a player is inside its dash blink window, + /// living enemies within of the player take damage — one hit per enemy per dash. SERVER-ONLY + /// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage + /// pattern), inside the predicted group after (dash state committed) and before + /// HealthApplyDamageSystem (the DamageEvent drains the same tick). Enemies carry no DashState, so the + /// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless. + /// + /// Dedup is keyed to (which is TickUtil.NonZero(now) on every dash and has + /// NO reliable clear edge on a release server): is cleared whenever the current + /// StartTick differs from . Server-only ⇒ no rollback, so persisting the + /// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the + /// per-tick dash step (<~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops + /// adding (a possible re-hit on a very crowded dash — accepted v1 cap). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] + [UpdateAfter(typeof(DashSystem))] + [UpdateBefore(typeof(HealthApplyDamageSystem))] + public partial struct DashTrailDamageSystem : ISystem + { + const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable) + const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable) + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var nt = SystemAPI.GetSingleton(); + var serverTick = nt.ServerTick; + if (!serverTick.IsValid) + return; + + // Snapshot living enemies once (positions + radii + entities), stable query order. + var enemyEntities = new NativeList(Allocator.Temp); + var enemyPositions = new NativeList(Allocator.Temp); + var enemyRadii = new NativeList(Allocator.Temp); + foreach (var (tx, hr, hp, te) in + SystemAPI.Query, RefRO, RefRO>() + .WithAll().WithNone().WithEntityAccess()) + { + if (hp.ValueRO.Current <= 0f) continue; + enemyEntities.Add(te); + enemyPositions.Add(tx.ValueRO.Position); + enemyRadii.Add(hr.ValueRO.Value); + } + + if (enemyEntities.Length == 0) + { + enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose(); + return; + } + + uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); + var ecb = new EntityCommandBuffer(Allocator.Temp); + + foreach (var (xform, dash, trail, owner, fx) in + SystemAPI.Query, RefRO, RefRW, + RefRO, RefRO>() + .WithAll()) + { + if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0) + continue; + + uint startRaw = dash.ValueRO.StartTick; + if (startRaw == 0u) + continue; // never dashed + + // Inside the blink (i-frame) window [StartTick, IFrameUntilTick)? + var startTick = new NetworkTick(startRaw); + var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick); + bool dashing = startTick.IsValid && untilTick.IsValid + && !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick); + if (!dashing) + continue; + + // New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear). + if (trail.ValueRO.LastStartTick != startRaw) + { + trail.ValueRW.Hit.Clear(); + trail.ValueRW.LastStartTick = startRaw; + } + + float3 p = xform.ValueRO.Position; + int ownerId = owner.ValueRO.NetworkId; + for (int i = 0; i < enemyEntities.Length; i++) + { + var enemy = enemyEntities[i]; + if (HitContains(trail.ValueRO, enemy)) + continue; + float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z); + float reach = k_Radius + enemyRadii[i]; + if (math.lengthsq(d) > reach * reach) + continue; + + if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick) + ecb.AppendToBuffer(enemy, new DamageEvent + { + Amount = k_Damage, + SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit) + SourceTick = stamp, + }); + if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity) + trail.ValueRW.Hit.Add(enemy); + } + } + + ecb.Playback(state.EntityManager); + ecb.Dispose(); + enemyEntities.Dispose(); + enemyPositions.Dispose(); + enemyRadii.Dispose(); + } + + static bool HitContains(in DashTrailState trail, Entity e) + { + for (int i = 0; i < trail.Hit.Length; i++) + if (trail.Hit[i] == e) return true; + return false; + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta new file mode 100644 index 000000000..2e4ca081a --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2a00802a81103745a1d20475a3c7b7b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs index b878a8e5b..9ac5818ff 100644 --- a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs @@ -77,6 +77,7 @@ namespace ProjectM.Server bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); uint negatedForThisEntity = 0u; float total = 0f; + int killerNetId = -1; // Phase 1.7: last player-sourced (non-negated) hit this tick → on-kill boon credit for (int i = 0; i < dmg.Length; i++) { uint src = dmg[i].SourceTick; @@ -96,6 +97,7 @@ namespace ProjectM.Server } } total += dmg[i].Amount; + if (dmg[i].SourceNetworkId >= 0) killerNetId = dmg[i].SourceNetworkId; // Phase 1.7 kill credit // MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's // whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1. @@ -149,6 +151,8 @@ namespace ProjectM.Server ecb.AddComponent(entity, new Dying { UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks), + KillerNetId = killerNetId, // Phase 1.7: KillRewardSystem reads this for Siphon/Frenzy + Rewarded = 0, }); if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(AttackWindup)); if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(KnockbackState)); diff --git a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs new file mode 100644 index 000000000..77ec468b7 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs @@ -0,0 +1,104 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Phase 1.7 on-kill boons. When HealthApplyDamageSystem stamps an enemy it records the + /// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse: + /// heals the killer (clamped to ) and + /// refreshes a short cooldown-reduction buff ( — + /// re-stamped, never stacked). Idempotent via the latch (a value write, no edge-detect). + /// + /// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW + /// access, which would alias that system's RefRW<Health> victim query. Here the + /// only query is RefRW<Dying> over enemies, and all killer writes go through ComponentLookup/BufferLookup + /// on player entities — no aliasing. Server-only (no rollback) inside the predicted group, after damage application. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] + [UpdateAfter(typeof(HealthApplyDamageSystem))] + public partial struct KillRewardSystem : ISystem + { + ComponentLookup m_Fx; + ComponentLookup m_Health; + ComponentLookup m_EffChar; + BufferLookup m_Mods; + BufferLookup m_Timed; + + const float k_SiphonHeal = 8f; // HP restored per kill (tunable) + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + m_Fx = state.GetComponentLookup(isReadOnly: true); + m_Health = state.GetComponentLookup(isReadOnly: false); + m_EffChar = state.GetComponentLookup(isReadOnly: true); + m_Mods = state.GetBufferLookup(isReadOnly: false); + m_Timed = state.GetBufferLookup(isReadOnly: false); + state.RequireForUpdate(); + state.RequireForUpdate(); // only run while a fresh corpse exists + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + + m_Fx.Update(ref state); + m_Health.Update(ref state); + m_EffChar.Update(ref state); + m_Mods.Update(ref state); + m_Timed.Update(ref state); + + // Resolve killers by NetworkId (players only). + var playerByNet = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByNet[owner.ValueRO.NetworkId] = e; + + uint until = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.FrenzyDurationTicks)); + + foreach (var (dying, corpse) in SystemAPI.Query>().WithAll().WithEntityAccess()) + { + if (dying.ValueRO.Rewarded != 0) + continue; + dying.ValueRW.Rewarded = 1; // mark ONCE — idempotent even when the killer can't be resolved + + int killerNet = dying.ValueRO.KillerNetId; + if (killerNet < 0 || !playerByNet.TryGetValue(killerNet, out var killer)) + continue; + if (!m_Fx.HasComponent(killer)) + continue; + byte flags = m_Fx[killer].Flags; + + // Siphon: heal the killer, clamped to their effective max (no over-heal; skip a corpse killer). + if ((flags & BoonFlag.Siphon) != 0 && m_Health.HasComponent(killer)) + { + var h = m_Health[killer]; + if (h.Current > 0f) + { + float max = m_EffChar.HasComponent(killer) ? m_EffChar[killer].MaxHealth : h.Max; + h.Current = math.min(h.Current + k_SiphonHeal, max); + m_Health[killer] = h; + } + } + + // Frenzy: refresh (never stack) a short cooldown-reduction buff on the killer. + if ((flags & BoonFlag.Frenzy) != 0 && m_Mods.HasBuffer(killer) && m_Timed.HasBuffer(killer)) + { + TimedModifierUtil.Upsert(m_Mods[killer], m_Timed[killer], Tuning.FrenzySourceId, + (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, Tuning.FrenzyCooldownMult, until); + } + } + + playerByNet.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta new file mode 100644 index 000000000..0f57676e3 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 43348399863cc454a8752cce54cc329d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index cc2e8afc7..fa6e9126c 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -24,12 +24,18 @@ namespace ProjectM.Server /// A target whose matches the projectile's owner is skipped (no self-hits); /// dummies carry no and are therefore always valid targets. /// + /// Phase 1.7 mechanic-changer boons ride the server-only (seeded at + /// spawn by AbilityFireSystem from the owner's ): PIERCE lets the projectile + /// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated) + /// toward the next-nearest living enemy, and PULL flips the knockback heading toward the shooter. A per-projectile + /// hit-set is excluded DURING target selection so a surviving projectile never re-hits a target across ticks; the + /// set full (or no pierce/chain left) destroys as before. Projectiles WITHOUT the component behave exactly as + /// before (destroy on hit) — graceful degradation. Exactly ONE destroy per projectile per tick is preserved. + /// /// On a hit the system appends a to the target (consumed by - /// HealthApplyDamageSystem) and destroys the projectile. Deferring damage to a buffer lets a - /// single tick stack hits from multiple projectiles. All structural changes go through an - /// that plays back immediately to the - /// (Temp allocator) — keeping this server-only, once-per-tick system - /// self-contained and plain-world testable without a separate ECB system. + /// HealthApplyDamageSystem). Deferring damage to a buffer lets a single tick stack hits from multiple + /// projectiles. All structural changes go through an that plays back + /// immediately to the (Temp allocator). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] @@ -47,17 +53,22 @@ namespace ProjectM.Server /// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups. ComponentLookup m_BossLookup; + /// RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set. + ComponentLookup m_FxLookup; /// Extra forgiveness added to a target's hit radius to approximate the projectile's own size. const float k_ProjectileRadius = 0.2f; + /// Max planar distance a Ricochet chain will reach for its next target (tunable). + const float k_ChainRange = 8f; + [BurstCompile] public void OnCreate(ref SystemState state) { m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_BossLookup = state.GetComponentLookup(isReadOnly: true); - + m_FxLookup = state.GetComponentLookup(isReadOnly: false); // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. state.RequireForUpdate(); @@ -69,6 +80,7 @@ namespace ProjectM.Server m_GhostOwnerLookup.Update(ref state); m_KnockbackLookup.Update(ref state); m_BossLookup.Update(ref state); + m_FxLookup.Update(ref state); bool haveTick = SystemAPI.TryGetSingleton(out var nt); @@ -92,11 +104,14 @@ namespace ProjectM.Server } foreach (var (xform, proj, owner, projectileEntity) in - SystemAPI.Query, RefRO, RefRO>() + SystemAPI.Query, RefRW, RefRO>() .WithEntityAccess()) { int projOwnerId = owner.ValueRO.NetworkId; + bool hasFx = m_FxLookup.HasComponent(projectileEntity); + ProjectileEffectState fx = hasFx ? m_FxLookup[projectileEntity] : default; + // This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather // than testing only segEnd) is what prevents fast projectiles from tunnelling targets. float3 cur = xform.ValueRO.Position; @@ -117,6 +132,11 @@ namespace ProjectM.Server m_GhostOwnerLookup[target].NetworkId == projOwnerId) continue; + // Phase 1.7: a surviving (pierced/chained) projectile never re-hits a target already struck — + // excluded DURING selection, not post-filtered. + if (hasFx && HitSetContains(in fx, target)) + continue; + float2 tp = new float2(targetPositions[i].x, targetPositions[i].z); // Closest point on the travel segment to the target centre. @@ -135,23 +155,68 @@ namespace ProjectM.Server if (bestIdx >= 0) { - // Earliest target along the travel path: deal damage and consume the projectile. - ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent + var hitTarget = targetEntities[bestIdx]; + + // Earliest target along the travel path: deal damage. + ecb.AppendToBuffer(hitTarget, new DamageEvent { Amount = proj.ValueRO.Damage, SourceNetworkId = projOwnerId, SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u, }); - var hitTarget = targetEntities[bestIdx]; + + // Knockback (Phase 1.7: PULL flips the heading toward the shooter when the owner's boon is set). if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget)) { + bool pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0; + float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction; m_KnockbackLookup[hitTarget] = new KnockbackState { - Dir = proj.ValueRO.Direction, + Dir = kdir, Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), }; } + + // Phase 1.7: pierce/chain let the projectile SURVIVE; else it is consumed. Record the target so it + // can't be re-hit. A full hit-set is a natural cap → destroy. + bool survive = false; + if (hasFx) + { + if (fx.Hit.Length < fx.Hit.Capacity) + { + fx.Hit.Add(hitTarget); + if (fx.PierceRemaining > 0) + { + fx.PierceRemaining = (byte)(fx.PierceRemaining - 1); + survive = true; + } + else if (fx.ChainRemaining > 0) + { + int nextIdx = FindChainTarget(targetEntities, targetPositions, cur, projOwnerId, in fx); + if (nextIdx >= 0) + { + float2 to = new float2(targetPositions[nextIdx].x - cur.x, targetPositions[nextIdx].z - cur.z); + if (math.lengthsq(to) > 1e-6f) + { + proj.ValueRW.Direction = math.normalize(to); + fx.ChainRemaining = (byte)(fx.ChainRemaining - 1); + survive = true; + } + } + } + } + m_FxLookup[projectileEntity] = fx; + } + + if (survive) + { + // A surviving projectile still expires once it has travelled its full range. + if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range) + ecb.DestroyEntity(projectileEntity); + continue; + } + ecb.DestroyEntity(projectileEntity); continue; } @@ -168,5 +233,38 @@ namespace ProjectM.Server targetPositions.Dispose(); targetRadii.Dispose(); } + + /// True when is already in the projectile's re-hit history. + static bool HitSetContains(in ProjectileEffectState fx, Entity target) + { + for (int i = 0; i < fx.Hit.Length; i++) + if (fx.Hit[i] == target) return true; + return false; + } + + /// Nearest living target to within that is neither + /// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1. + int FindChainTarget(in NativeList targetEntities, in NativeList targetPositions, + float3 from, int projOwnerId, in ProjectileEffectState fx) + { + int best = -1; + float bestDistSq = k_ChainRange * k_ChainRange; + for (int i = 0; i < targetEntities.Length; i++) + { + var target = targetEntities[i]; + if (m_GhostOwnerLookup.HasComponent(target) && m_GhostOwnerLookup[target].NetworkId == projOwnerId) + continue; + if (HitSetContains(in fx, target)) + continue; + float2 d = new float2(targetPositions[i].x - from.x, targetPositions[i].z - from.z); + float dsq = math.lengthsq(d); + if (dsq <= bestDistSq) + { + bestDistSq = dsq; + best = i; + } + } + return best; + } } } diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs index 9cc28fbfe..c86c96eac 100644 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs @@ -389,14 +389,18 @@ case RunLifecycle.RouteSelect: // boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the // effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are // disjoint and survive. Idempotent — safe on every Returning tick. - foreach (var (mods, offer) in - SystemAPI.Query, RefRW>().WithAll()) + foreach (var (mods, timed, fx, offer) in + SystemAPI.Query, DynamicBuffer, RefRW, RefRW>().WithAll()) { TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan); TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase, Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too + // Phase 1.7: zero the mechanic-changer boons + strip the stale Frenzy timed row (its paired + // StatModifier is already cleared by the boon-band range-strip above). + fx.ValueRW = default; + TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId); offer.ValueRW = default; } diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 5e1834cba..055222762 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -21,6 +21,13 @@ namespace ProjectM.Simulation /// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and /// predicted + server projectiles match (both folded the same replicated modifiers). /// + /// Phase 1.7 mechanic-changer boons ride the owner-replicated (SendToOwner, so the + /// predicting owner has it — the .WithAll<Simulate>() filter means only the owner's own player is processed + /// client-side), read via a ComponentLookup keyed by the player (the query is already at the 7-type SystemAPI + /// limit). FORK fans Fork extra predicted projectiles in a symmetric spread, each with a UNIQUE + /// deterministic SpawnId (fork index packed into the low bits) so classification predicts each. PIERCE/CHAIN/PULL + /// are seeded into the server-only at spawn (resolved by ProjectileDamageSystem). + /// /// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all /// non-idempotent effects (spawning, cooldown advance) are gated behind /// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute @@ -40,6 +47,11 @@ namespace ProjectM.Simulation // C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use. ComponentLookup m_KnockbackLookup; ComponentLookup m_BossLookup; + // Phase 1.7: owner-replicated mechanic-changer boons, read by the player entity (query is at the 7-type cap). + ComponentLookup m_BoonEffectsLookup; + + /// ~9° gap between adjacent Split-Shot projectiles (tunable). + const float k_ForkSpreadRad = 0.157f; [BurstCompile] public void OnCreate(ref SystemState state) @@ -48,6 +60,7 @@ namespace ProjectM.Simulation state.RequireForUpdate(); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_BossLookup = state.GetComponentLookup(isReadOnly: true); + m_BoonEffectsLookup = state.GetComponentLookup(isReadOnly: true); } [BurstCompile] @@ -71,6 +84,7 @@ namespace ProjectM.Simulation bool isServer = state.WorldUnmanaged.IsServer(); m_KnockbackLookup.Update(ref state); m_BossLookup.Update(ref state); + m_BoonEffectsLookup.Update(ref state); // Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad // auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave. @@ -113,6 +127,10 @@ namespace ProjectM.Simulation continue; // still cooling down } + // Phase 1.7 mechanic-changer boons (owner-replicated; read by entity — see class doc for the 7-type cap). + BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default; + bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0; + // MC-4 spike for MC-6: dispatch on the authored ability ARCHETYPE byte (baked in the blob, read here -- NOT // folded through EffectiveAbilityStats; it is static identity, not a tunable stat). All current // abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6. @@ -143,9 +161,10 @@ namespace ProjectM.Simulation }); // C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee // (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4). + // Phase 1.7 Gravity Pull: drag toward the player instead of away. KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci], xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed, - TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks))); + TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull); } } uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks); @@ -195,28 +214,50 @@ namespace ProjectM.Simulation candidates); } - uint spawnId = (uint)owner.ValueRO.NetworkId << 16 | absoluteFireCount; + // Phase 1.7 mechanic-changer seeds. Fork fans (1 + Fork) shots in a symmetric spread; each carries the + // pierce/chain/pull state into its server-only ProjectileEffectState. + byte pierce = bfx.Pierce; + byte chain = bfx.Chain; + byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0; + int shots = 1 + math.min((int)bfx.Fork, 8); // cap forks: forkIndex is 4 spawnId bits (no wrap) + a sane spread ceiling - var projectile = ecb.Instantiate(prefab); - - float3 planarDir = new float3(dir.x, 0f, dir.y); - float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f; - spawnPos.y = xform.ValueRO.Position.y; - quaternion rot = quaternion.LookRotationSafe(planarDir, math.up()); - - ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot)); - ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId }); - // Snapshot the effective ability stats into the projectile (base + modifiers, computed - // identically on both worlds), so the move/damage systems need no modifier lookup. - ecb.SetComponent(projectile, new Projectile + for (int s = 0; s < shots; s++) { - Direction = math.normalize(dir), - SpawnId = spawnId, - Speed = eff.ValueRO.ProjectileSpeed, - Damage = eff.ValueRO.Damage, - Range = eff.ValueRO.Range, - DistanceTravelled = 0f, - }); + // Symmetric fan around the (assisted) aim heading; s=0 is centred when there is no fork. + float offset = (s - (shots - 1) * 0.5f) * k_ForkSpreadRad; + math.sincos(offset, out float sa, out float ca); + float2 sdir = math.normalize(new float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca)); + + // Unique deterministic classification key: owner(16) | fireCount(12) | forkIndex(4). + uint spawnId = (((uint)owner.ValueRO.NetworkId) << 16) | ((absoluteFireCount & 0x0FFFu) << 4) | (uint)(s & 0xF); + + var projectile = ecb.Instantiate(prefab); + float3 planarDir = new float3(sdir.x, 0f, sdir.y); + float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f; + spawnPos.y = xform.ValueRO.Position.y; + quaternion rot = quaternion.LookRotationSafe(planarDir, math.up()); + + ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot)); + ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId }); + // Snapshot the effective ability stats into the projectile (base + modifiers, computed + // identically on both worlds), so the move/damage systems need no modifier lookup. + ecb.SetComponent(projectile, new Projectile + { + Direction = sdir, + SpawnId = spawnId, + Speed = eff.ValueRO.ProjectileSpeed, + Damage = eff.ValueRO.Damage, + Range = eff.ValueRO.Range, + DistanceTravelled = 0f, + }); + // Server-only pierce/chain/pull seed (baked inert on the prefab; harmless on the client copy). + ecb.SetComponent(projectile, new ProjectileEffectState + { + PierceRemaining = pierce, + ChainRemaining = chain, + Flags = projFlags, + }); + } // Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick. uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks); diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs index edd0b81d1..10b477ff7 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs @@ -4,25 +4,48 @@ using Unity.Entities; namespace ProjectM.Simulation { /// - /// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline — - /// // map 1:1 onto a row - /// (bytes, never enums, on the baked path). is the stable APPEND-ONLY key the replicated - /// BoonOffer options and pick RPC carry. is the rarity draw weight - /// (common 100 / rare 30 / epic 10). gates by class: bit0 = Warrior (classId 0), - /// bit1 = Ranger (classId 1), 3 = both. + /// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (==0 — + /// // map 1:1 onto a row, the + /// original path) OR a Phase-1.7 MECHANIC-CHANGER (==1 — selects the + /// hook; for the stacking kinds Pierce/Fork/Chain is the per-pick count delta, else it's a + /// flag). Bytes, never enums, on the baked path. is the stable key the replicated + /// BoonOffer options + pick RPC carry. is the rarity draw weight (common 100 / + /// uncommon 60 / rare 30 / epic 10). : bit0 = Warrior (classId 0), bit1 = Ranger + /// (classId 1), 3 = both. tags synergy/dedup — no two same-family options in one deal. /// public struct BoonDefBlob { public byte Id; - public byte Target; // StatTarget as byte - public byte Op; // ModOp as byte - public float Value; + public byte Target; // StatTarget as byte (Kind==0) + public byte Op; // ModOp as byte (Kind==0) + public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta public byte Weight; public byte ClassMask; + public byte Kind; // 0 = stat, 1 = mechanic-changer (Phase 1.7) + public byte EffectKind; // BoonEffectKind byte (Kind==1) + public byte Family; // BoonFamily byte — dedup/dominated/bias tag public FixedString64Bytes Name; public FixedString128Bytes Desc; } + /// + /// Synergy/dedup tags for . Bytes (Burst-safe). No two options of the same + /// family are offered in one deal (dominated-offer protection — kills the "-15% vs -25% cooldown" case); owning + /// a mechanic family biases future offers toward it (light build-bias). + /// + public static class BoonFamily + { + public const byte None = 0; + public const byte Projectile = 1; + public const byte Melee = 2; + public const byte Mobility = 3; + public const byte OnKill = 4; + public const byte StatDamage = 5; + public const byte StatHealth = 6; + public const byte StatSpeed = 7; + public const byte StatCooldown = 8; + } + /// The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern). public struct BoonCatalogBlob { @@ -45,8 +68,10 @@ namespace ProjectM.Simulation } /// - /// Pure, deterministic boon selection math — integer-hash only ( chain, - /// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested. + /// Pure, deterministic boon selection math — integer-hash only (RunMapMath.Hash chain, no RNG state), so + /// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is + /// safe because BoonOfferSystem draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch) + /// — the client never re-runs it. EditMode-tested. /// public static class BoonMath { @@ -54,63 +79,75 @@ namespace ProjectM.Simulation public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1)); /// - /// Draw 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per - /// . If the class-legal pool has fewer than 3 entries the tail repeats the - /// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids. + /// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per + /// (, ). Phase 1.7: a non-stacking FLAG effect the player + /// already owns is excluded (dedup); no two options share a in one deal + /// (dominated-offer protection); a candidate whose family matches an owned effect's family draws at ×1.5 + /// weight (light build-bias). Falls back deterministically when draws collide. Returns the number of + /// distinct ids (tail repeats the last when the legal pool has fewer than 3). /// - public static int PickBoons(uint offerSeed, byte classId, ref BoonCatalogBlob pool, + public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool, out byte o0, out byte o1, out byte o2) { byte classBit = MaskFor(classId); + int ownedFamilies = OwnedFamilyMask(owned); - // Class-legal candidate indices + the total weight. - var candidates = new FixedList128Bytes(); + var candidates = new FixedList128Bytes(); // catalog indices + var weights = new FixedList128Bytes(); // biased draw weight per candidate (parallel) int totalWeight = 0; for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++) { if ((pool.Defs[i].ClassMask & classBit) == 0) continue; if (pool.Defs[i].Weight == 0) continue; + if (IsOwnedFlag(pool.Defs[i], owned)) continue; // non-stacking flag already held → dedup + int w = pool.Defs[i].Weight; + byte fam = pool.Defs[i].Family; + if (fam != 0 && (ownedFamilies & (1 << fam)) != 0) + w += w / 2; // ×1.5 build-bias (integer) + if (w > 255) w = 255; candidates.Add((byte)i); - totalWeight += pool.Defs[i].Weight; + weights.Add((byte)w); + totalWeight += w; } o0 = o1 = o2 = 0; if (candidates.Length == 0) return 0; - var picked = new FixedList32Bytes(); // picked catalog indices + var picked = new FixedList32Bytes(); // picked catalog indices + var pickedFamilies = new FixedList32Bytes(); // families used this deal (fam != 0) uint salt = 0; while (picked.Length < 3 && picked.Length < candidates.Length) { - // Weighted draw with rejection on duplicates (bounded; falls through to a linear fill). uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight; - byte drawn = candidates[candidates.Length - 1]; + int chosen = candidates.Length - 1; int acc = 0; for (int c = 0; c < candidates.Length; c++) { - acc += pool.Defs[candidates[c]].Weight; - if (roll < (uint)acc) { drawn = candidates[c]; break; } + acc += weights[c]; + if (roll < (uint)acc) { chosen = c; break; } } + byte drawn = candidates[chosen]; + byte fam = pool.Defs[drawn].Family; bool dup = false; for (int p = 0; p < picked.Length; p++) if (picked[p] == drawn) { dup = true; break; } + bool famClash = false; + if (!dup && fam != 0) + for (int p = 0; p < pickedFamilies.Length; p++) + if (pickedFamilies[p] == fam) { famClash = true; break; } - if (!dup) + if (!dup && !famClash) { picked.Add(drawn); + if (fam != 0) pickedFamilies.Add(fam); salt = 0; } else if (++salt > 16) { - // Rejection budget spent — take the first unpicked candidate (still deterministic). - for (int c = 0; c < candidates.Length; c++) - { - bool used = false; - for (int p = 0; p < picked.Length; p++) - if (picked[p] == candidates[c]) { used = true; break; } - if (!used) { picked.Add(candidates[c]); break; } - } + // Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible). + AddFallback(ref picked, ref pickedFamilies, candidates, ref pool); salt = 0; } } @@ -121,6 +158,63 @@ namespace ProjectM.Simulation return picked.Length; } + /// Deterministic tail-fill when the weighted draw keeps colliding: take the first unused candidate + /// that is family-distinct from the deal; if none, the first unused (family clash tolerated as last resort so + /// the deal never wedges below 3 while candidates remain). + static void AddFallback(ref FixedList32Bytes picked, ref FixedList32Bytes pickedFamilies, + in FixedList128Bytes candidates, ref BoonCatalogBlob pool) + { + int firstUnused = -1; + for (int c = 0; c < candidates.Length; c++) + { + byte cand = candidates[c]; + bool used = false; + for (int p = 0; p < picked.Length; p++) + if (picked[p] == cand) { used = true; break; } + if (used) continue; + if (firstUnused < 0) firstUnused = cand; + byte cfam = pool.Defs[cand].Family; + bool clash = false; + if (cfam != 0) + for (int p = 0; p < pickedFamilies.Length; p++) + if (pickedFamilies[p] == cfam) { clash = true; break; } + if (clash) continue; + picked.Add(cand); + if (cfam != 0) pickedFamilies.Add(cfam); + return; + } + if (firstUnused >= 0) + picked.Add((byte)firstUnused); + } + + /// True when a candidate is a non-stacking FLAG effect the player already owns (dedup). Pierce/Fork/ + /// Chain STACK, so they're never excluded. Byte switch — Burst-safe. + static bool IsOwnedFlag(in BoonDefBlob d, in BoonEffects owned) + { + if (d.Kind != 1) return false; + switch (d.EffectKind) + { + case BoonEffectKind.DashTrail: return (owned.Flags & BoonFlag.DashTrail) != 0; + case BoonEffectKind.FinisherDetonate: return (owned.Flags & BoonFlag.FinisherDetonate) != 0; + case BoonEffectKind.KnockToPull: return (owned.Flags & BoonFlag.KnockToPull) != 0; + case BoonEffectKind.Siphon: return (owned.Flags & BoonFlag.Siphon) != 0; + case BoonEffectKind.Frenzy: return (owned.Flags & BoonFlag.Frenzy) != 0; + default: return false; + } + } + + /// Bitmask (indexed by value) of the MECHANIC families the player owns — + /// drives the ×1.5 build-bias. Stat families are never marked (build-bias is mechanic-synergy only). + static int OwnedFamilyMask(in BoonEffects owned) + { + int m = 0; + if (owned.Pierce != 0 || owned.Fork != 0 || owned.Chain != 0) m |= 1 << BoonFamily.Projectile; + if ((owned.Flags & (BoonFlag.FinisherDetonate | BoonFlag.KnockToPull)) != 0) m |= 1 << BoonFamily.Melee; + if ((owned.Flags & BoonFlag.DashTrail) != 0) m |= 1 << BoonFamily.Mobility; + if ((owned.Flags & (BoonFlag.Siphon | BoonFlag.Frenzy)) != 0) m |= 1 << BoonFamily.OnKill; + return m; + } + /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). public static int FindDef(ref BoonCatalogBlob pool, byte id) { @@ -131,8 +225,9 @@ namespace ProjectM.Simulation } /// - /// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the - /// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids. + /// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers + /// (==1) + 4 strong flat-stat boons (Kind==0). Ids are within-session stable (both + /// worlds bake the same code; boons never persist across saves — stripped on the Returning edge). /// public static class BoonCatalogData { @@ -143,25 +238,28 @@ namespace ProjectM.Simulation ref var root = ref builder.ConstructRoot(); var defs = builder.Allocate(ref root.Defs, 12); int i = 0; - // id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc - defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage"); - defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown"); - defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range"); - defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed"); - defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health"); - defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage"); - defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach"); - defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed"); - defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range"); - defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown"); - defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health"); - defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage"); + // ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only + // (the Warrior's Fire is a cone, not a projectile). ---- + defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy"); + defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread"); + defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy"); + defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE"); + defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through"); + defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN"); + defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you"); + defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities"); + // ---- 4 strong flat-stat boons (Kind=0) ---- + defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage"); + defs[i++] = Stat(10, StatTarget.MaxHealth, ModOp.Flat, 60f, 100, 3, BoonFamily.StatHealth, "Titan's Vigor", "+60 max health"); + defs[i++] = Stat(11, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.18f, 100, 3, BoonFamily.StatSpeed, "Fleet Foot", "+18% move speed"); + defs[i++] = Stat(12, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 60, 3, BoonFamily.StatCooldown, "Berserker's Pace", "-25% ability cooldown"); var blob = builder.CreateBlobAssetReference(allocator); builder.Dispose(); return blob; } - static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, + /// A flat-stat boon row (Kind=0 — appends a ). + static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family, string name, string desc) { return new BoonDefBlob @@ -172,6 +270,30 @@ namespace ProjectM.Simulation Value = value, Weight = weight, ClassMask = mask, + Kind = 0, + EffectKind = BoonEffectKind.None, + Family = family, + Name = new FixedString64Bytes(name), + Desc = new FixedString128Bytes(desc), + }; + } + + /// A mechanic-changer boon row (Kind=1 — mutates ). + /// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects. + static BoonDefBlob Effect(byte id, byte effectKind, float value, byte weight, byte mask, byte family, + string name, string desc) + { + return new BoonDefBlob + { + Id = id, + Target = 0, + Op = 0, + Value = value, + Weight = weight, + ClassMask = mask, + Kind = 1, + EffectKind = effectKind, + Family = family, Name = new FixedString64Bytes(name), Desc = new FixedString128Bytes(desc), }; diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs new file mode 100644 index 000000000..2ceeb5729 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs @@ -0,0 +1,61 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat + /// band. Stackable counts (//) + /// and boolean (see ) that combat systems read to alter behaviour. + /// + /// Replicated (matching BoonOffer): rollback-correctness is + /// provided by the [GhostField]s themselves — the owner is the sole predicting client and needs the + /// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in AbilityFireSystem, which + /// filters .WithAll<Simulate>()) don't mispredict. Non-owning clients render forked/pierced/chained + /// projectiles as interpolated server ghosts and never read the shooter's effects; every other read is + /// server-only. NOT — the send type is not what enables rollback, the + /// [GhostField] is. + /// + /// Baked INERT (all 0) on the player prefab (the BoonOffer idiom) so a pick is a non-structural mutate; + /// zeroed on the Returning edge in RunDirectorSystem alongside the StatModifier band strips. + /// + [GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] + public struct BoonEffects : IComponentData + { + /// Extra enemy hits a projectile survives before despawning (stacks). + [GhostField] public byte Pierce; + /// Extra spread projectiles spawned per shot (stacks). + [GhostField] public byte Fork; + /// Targets a projectile chains to after a hit (stacks). + [GhostField] public byte Chain; + /// Boolean effect bits — see . + [GhostField] public byte Flags; + } + + /// Bit masks for . Plain byte consts (never an enum compared in Burst). + public static class BoonFlag + { + public const byte DashTrail = 1; // dashing damages enemies passed through + public const byte FinisherDetonate = 2; // the melee combo finisher blasts an AoE + public const byte KnockToPull = 4; // this player's knockback pulls enemies IN instead of away + public const byte Siphon = 8; // killing an enemy heals this player + public const byte Frenzy = 16; // a kill grants a short cooldown-reduction surge + } + + /// + /// Stable byte discriminator for a BoonDefBlob mechanic-changer effect (0 = a plain stat boon). + /// Bytes only — Burst-safe, never an enum compared inside a Bursted system. + /// + public static class BoonEffectKind + { + public const byte None = 0; + public const byte Pierce = 1; + public const byte Fork = 2; + public const byte Chain = 3; + public const byte DashTrail = 4; + public const byte FinisherDetonate = 5; + public const byte KnockToPull = 6; + public const byte Siphon = 7; + public const byte Frenzy = 8; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta new file mode 100644 index 000000000..c0df804d6 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76c925707efba46478fb9c697d391e0d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/Dying.cs b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs index 395fc9e41..a02eda4ef 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/Dying.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs @@ -15,5 +15,13 @@ namespace ProjectM.Simulation { /// Server tick the corpse despawns (via TickUtil.NonZero; compared via NetworkTick). public uint UntilTick; + + /// Phase 1.7: NetworkId of the player credited with the kill (the last player-sourced DamageEvent + /// drained this tick), or -1 if none. Read once by KillRewardSystem for on-kill boons (Siphon/Frenzy). + public int KillerNetId; + + /// Phase 1.7: 0 until KillRewardSystem has granted this corpse's on-kill rewards (idempotent + /// value latch — no structural change, no edge-detection). + public byte Rewarded; } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs b/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs index 471261f03..6b83bca08 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs @@ -15,13 +15,14 @@ namespace ProjectM.Simulation static class KnockbackUtil { public static void Stamp(ref ComponentLookup lookup, in ComponentLookup bossLookup, - Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick) + Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick, bool pull = false) { if (!lookup.HasComponent(target) || bossLookup.HasComponent(target)) return; float3 delta = targetPos - sourcePos; float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback; + if (pull) dir = -dir; // Phase 1.7 Gravity Pull: drag the target TOWARD the attacker lookup[target] = new KnockbackState { Dir = dir, Speed = speed, UntilTick = untilTick }; } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs new file mode 100644 index 000000000..303ce8da3 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs @@ -0,0 +1,35 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Phase 1.7 per-projectile mechanic-changer state — SERVER-ONLY, NOT a [GhostField] (mirrors + /// ): it adds no replicated surface, so the ghost hash + /// stays FROZEN (adding fields to the ghost component itself would change its + /// StableTypeHash → serializer hash → ghost re-bake; a separate server-only component does not). Baked inert + /// on the projectile prefab; seeded server-side at spawn (AbilityFireSystem) from the owner's + /// , and read only by ProjectileDamageSystem (also server-only) — the owner's + /// predicted projectile needs no local copy (pierce = server delays despawn → client reconciles via ghost + /// persistence; chain = server rewrites the replicated ; pull just flips the + /// server-stamped ). + /// + public struct ProjectileEffectState : IComponentData + { + /// Enemy hits remaining before the projectile despawns (0 = destroy on next hit). + public byte PierceRemaining; + /// Chain-to-next-target hops remaining after a hit. + public byte ChainRemaining; + /// bit0 = Pull (stamp knockback TOWARD the shooter instead of away). + public byte Flags; + /// Targets already hit by this projectile — excluded DURING target selection so a surviving + /// (pierced/chained) projectile never re-hits the same enemy across ticks. Overflow ⇒ destroy (natural cap). + public FixedList64Bytes Hit; + } + + /// Bit masks for . + public static class ProjectileEffectFlag + { + public const byte Pull = 1; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs.meta new file mode 100644 index 000000000..9c8e9bd59 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b54e702af1501ec408b0b09e23853f63 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs index 6d3c9611e..fcf8636ca 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs @@ -42,5 +42,32 @@ namespace ProjectM.Simulation if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; } return removed; } + + /// Phase 1.7: guarantee EXACTLY ONE row per in BOTH the replicated + /// buffer and this server-only buffer (remove-then-add) so a + /// timed buff REFRESHES (re-stamps ) rather than stacking on a repeat grant. Used by + /// KillRewardSystem for Frenzy so successive kills extend the surge instead of compounding the modifier. + public static void Upsert(DynamicBuffer mods, DynamicBuffer timed, + uint sourceId, byte target, byte op, float value, uint untilTick) + { + RemoveBySourceId(mods, sourceId); + for (int j = timed.Length - 1; j >= 0; j--) + if (timed[j].SourceId == sourceId) timed.RemoveAtSwapBack(j); + mods.Add(new StatModifier { Target = target, Op = op, Value = value, SourceId = sourceId }); + timed.Add(new TimedModifier { SourceId = sourceId, UntilTick = untilTick }); + } + + /// Phase 1.7: remove every server-only row matching + /// (the paired is cleared separately — e.g. the Returning boon-band range-strip). This + /// closes the cross-run gap where a stale Frenzy timed row could outlive its StatModifier. Returns the count removed. + public static int RemoveBySourceId(DynamicBuffer timed, uint sourceId) + { + int removed = 0; + for (int j = timed.Length - 1; j >= 0; j--) + if (timed[j].SourceId == sourceId) { timed.RemoveAtSwapBack(j); removed++; } + return removed; + } + + } } diff --git a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs new file mode 100644 index 000000000..e0531c3c3 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs @@ -0,0 +1,21 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a [GhostField], so no ghost-hash impact; + /// it piggybacks the player re-bake). Keys the per-dash "hit once" dedup to + /// (which is TickUtil.NonZero(now) on every dash and cannot be relied + /// upon to reset) rather than to any DashState clear edge: DashTrailDamageSystem clears + /// whenever the current StartTick differs from . Server-only (no rollback) so the + /// accumulator is safe to persist across ticks. + /// + public struct DashTrailState : IComponentData + { + /// The the set currently belongs to. + public uint LastStartTick; + /// Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash). + public FixedList64Bytes Hit; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta new file mode 100644 index 000000000..b11db19ce --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 248bd87d96cfc5b43b4681a203756c5c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs index f74755584..5d022cf46 100644 --- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -20,6 +20,9 @@ namespace ProjectM.Simulation public int OwnerId; public uint Stamp; public uint KnockUntil; + public bool IsFinisher; // Phase 1.7: this swing is the combo finisher + public bool Detonate; // Phase 1.7: attacker has the FinisherDetonate boon + public bool Pull; // Phase 1.7: attacker has the KnockToPull boon } /// @@ -54,6 +57,10 @@ namespace ProjectM.Simulation ComponentLookup m_RegionLookup; BufferLookup m_InvLookup; BufferLookup m_StatModLookup; + ComponentLookup m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup) + + /// Phase 1.7 Detonating Finisher blast radius (planar, tunable). + const float k_DetonateRadius = 3.5f; [BurstCompile] public void OnCreate(ref SystemState state) @@ -64,6 +71,7 @@ namespace ProjectM.Simulation m_RegionLookup = state.GetComponentLookup(isReadOnly: true); m_InvLookup = state.GetBufferLookup(isReadOnly: false); m_StatModLookup = state.GetBufferLookup(isReadOnly: true); + m_BoonEffectsLookup = state.GetComponentLookup(isReadOnly: true); state.RequireForUpdate(); } @@ -93,7 +101,8 @@ namespace ProjectM.Simulation // Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only // when at least one swing actually started — no per-tick enemy gather on idle/client ticks). var cleaves = isServer ? new NativeList(Allocator.Temp) : default; - m_StatModLookup.Update(ref state); // Slice 2: per-player melee stat fold (read inside the player loop) + m_StatModLookup.Update(ref state); + m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop) foreach (var (mc, control, input, facing, xform, owner, ds, entity) in SystemAPI.Query, RefRW, RefRO, @@ -147,6 +156,7 @@ namespace ProjectM.Simulation bool isFin = swingStep >= comboLen; // Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the // PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments. + byte bflags = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0; bool hasMods = m_StatModLookup.HasBuffer(entity); float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage); float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange); @@ -162,6 +172,9 @@ namespace ProjectM.Simulation OwnerId = owner.ValueRO.NetworkId, Stamp = stamp, KnockUntil = knockUntil, + IsFinisher = isFin, + Detonate = (bflags & BoonFlag.FinisherDetonate) != 0, + Pull = (bflags & BoonFlag.KnockToPull) != 0, }); } } @@ -254,9 +267,31 @@ namespace ProjectM.Simulation }); if (c.KnockSpeed > 0f) KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target, - c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil); + c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil, c.Pull); } } + // Phase 1.7 Detonating Finisher: a finisher swing with the boon blasts a planar AoE around its + // origin (mirrors HazardExplosionSystem). Cone+blast overlap is the normal DamageEvent-summation. + for (int s = 0; s < cleaves.Length; s++) + { + var dc = cleaves[s]; + if (!dc.IsFinisher || !dc.Detonate) + continue; + float detRadSq = k_DetonateRadius * k_DetonateRadius; + for (int i = 0; i < enemyEntities.Length; i++) + { + float2 dd = new float2(enemyPositions[i].x - dc.From.x, enemyPositions[i].z - dc.From.z); + if (math.lengthsq(dd) > detRadSq) + continue; + ecb.AppendToBuffer(enemyEntities[i], new DamageEvent + { + Amount = dc.Damage, + SourceNetworkId = dc.OwnerId, + SourceTick = dc.Stamp, + }); + } + } + // HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write // Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining. for (int s = 0; s < cleaves.Length; s++) diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 1be5f2015..64c2dc53c 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -208,7 +208,7 @@ namespace ProjectM.Simulation // inline mods share that one id and are stripped target-agnostically via // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): // 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; - // 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return); 0x00C1A550.. = class traits (permanent); + // 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return; top slot 0x00B0FFFF = Frenzy timed buff); 0x00C1A550.. = class traits (permanent); // 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots). /// Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase + @@ -219,6 +219,19 @@ namespace ProjectM.Simulation /// Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count. public const uint BoonSourceIdSpan = 0x10000u; + /// Phase 1.7: the single Frenzy on-kill timed buff's SourceId, pinned to the TOP of the boon band + /// (Base + Span - 1 = 0x00B0FFFF). The per-pick counter allocates from the BOTTOM (Base + counter % Span) and + /// cannot reach Span-1 within a run, so it never aliases a pick; the Returning whole-band RemoveBySourceIdRange + /// still clears it for free. Refreshed (never stacked) via TimedModifierUtil.Upsert. Do NOT copy the sibling-band + /// Base+smallIndex idiom into the boon band — its low offsets are counter-consumed. + public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF + + /// Phase 1.7: Frenzy surge duration (ticks, ~60/s) re-stamped on each kill. + public const int FrenzyDurationTicks = 240; + + /// Phase 1.7: Frenzy cooldown modifier (PercentMult on CooldownTicks; -0.30 = 30% faster abilities). + public const float FrenzyCooldownMult = -0.30f; + /// DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon /// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live /// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons. diff --git a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs index 6ae2c6d4d..ce7f6388c 100644 --- a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs +++ b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs @@ -11,10 +11,13 @@ using System.Collections.Generic; 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). + /// Pins the two-channel boon lifecycle (Phase 1.7 table). : a valid STAT pick appends + /// exactly ONE boon-band and clears Pending; a MECHANIC-CHANGER pick mutates + /// (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%). /// 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(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(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(player).Pending, "pick consumed"); Assert.AreEqual(1u, em.GetComponentData(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(player).Pierce, "Pierce incremented"); + Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); + Assert.AreEqual(0u, em.GetComponentData(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(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(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(player); + timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 }); 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"); + 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(player).Length, "Frenzy timed row stripped"); + Assert.AreEqual(default(BoonEffects), em.GetComponentData(player), "mechanic-changer effects zeroed"); Assert.AreEqual(0, em.GetComponentData(player).Pending, "straggler offer zeroed"); Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); } diff --git a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs index 7e3332752..023e2697e 100644 --- a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs +++ b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs @@ -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(); + } +} } diff --git a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs new file mode 100644 index 000000000..913d1c85f --- /dev/null +++ b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities tests for (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. + /// + public class DashTrailDamageSystemTests + { + static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick) + { + var world = new World("DashTrailTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(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(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(enemy).Length, "no Blade Dash boon -> no trail damage"); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta new file mode 100644 index 000000000..9cce847fa --- /dev/null +++ b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7603c5c6b91bb854d8b88739bdb6f4b1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs new file mode 100644 index 000000000..5c62fc2ce --- /dev/null +++ b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities tests for (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. + /// + public class KillRewardSystemTests + { + const uint T0 = 5000; + + static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld() + { + var world = new World("KillRewardTest"); + 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) }); + 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(e); + em.AddBuffer(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(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(killer).Current, 50f, "Siphon healed the killer"); + Assert.AreEqual(1, em.GetComponentData(corpse).Rewarded, "corpse latched as rewarded"); + + float afterFirst = em.GetComponentData(killer).Current; + group.Update(); // second tick: Rewarded==1 -> no double-heal + Assert.AreEqual(afterFirst, em.GetComponentData(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(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(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(corpse).Rewarded, "still latched so it is not reprocessed"); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta new file mode 100644 index 000000000..8b7552d9e --- /dev/null +++ b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e10a4fa71c531a42b093a1b43d1ccaf \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ProjectileDamageSystemTests.cs b/Assets/_Project/Tests/EditMode/ProjectileDamageSystemTests.cs index 5b4991af5..de7c4f8d7 100644 --- a/Assets/_Project/Tests/EditMode/ProjectileDamageSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/ProjectileDamageSystemTests.cs @@ -147,5 +147,65 @@ namespace ProjectM.Tests Assert.AreEqual(0, em.GetBuffer(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(); + 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(a).Length, "earliest target A is hit"); + Assert.AreEqual(0, em.GetBuffer(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(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(a).Length, "A must NOT be re-hit (hit-set exclusion)"); + Assert.AreEqual(1, em.GetBuffer(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(); + 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(a).Length, "A (on the path) is hit"); + Assert.AreEqual(0, em.GetBuffer(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(proj).ChainRemaining, "chain consumed"); + var dir = em.GetComponentData(proj).Direction; + Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)"); + } +} } diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs index 5bad504bf..7b2a9ecdf 100644 --- a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs +++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs @@ -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(); + void Add() where T : unmanaged, ISystem + => group.AddSystemToUpdateList(world.GetOrCreateSystem()); + + Add(); Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); + + Assert.DoesNotThrow(() => group.SortSystems(), + "A cycle in the Phase 1.7 predicted combat chain throws here instead of only at Play world-creation."); + } +} } diff --git a/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs b/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs new file mode 100644 index 000000000..03da1e0fd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Entities; + +namespace ProjectM.Tests +{ + /// + /// Pins (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 + /// overload (C5) clears the paired timed row. + /// + public class TimedModifierUtilTests + { + static (int stat, int timed, uint until) Count(DynamicBuffer mods, DynamicBuffer 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(e); + em.AddBuffer(e); + uint id = Tuning.FrenzySourceId; + + for (uint k = 1; k <= 3; k++) + TimedModifierUtil.Upsert(em.GetBuffer(e), em.GetBuffer(e), + id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 100u * k); + + var c = Count(em.GetBuffer(e), em.GetBuffer(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(e); + em.AddBuffer(e); + uint id = Tuning.FrenzySourceId; + TimedModifierUtil.Upsert(em.GetBuffer(e), em.GetBuffer(e), + id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 500u); + + TimedModifierUtil.RemoveBySourceId(em.GetBuffer(e), id); + TimedModifierUtil.RemoveBySourceId(em.GetBuffer(e), id); + + var c = Count(em.GetBuffer(e), em.GetBuffer(e), id); + Assert.AreEqual(0, c.stat, "StatModifier row stripped"); + Assert.AreEqual(0, c.timed, "TimedModifier row stripped"); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs.meta b/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs.meta new file mode 100644 index 000000000..53bf10d4a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TimedModifierUtilTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a4b41bc944a5f4340ad5eef53beb2cfc \ No newline at end of file