Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -32,6 +32,9 @@ namespace ProjectM.Authoring
Damage = authoring.Damage, Damage = authoring.Damage,
Range = authoring.Range 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<ProjectileEffectState>(entity);
} }
} }
} }
@@ -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). // flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up).
AddComponent<PlayerReady>(entity); AddComponent<PlayerReady>(entity);
AddComponent<BoonOffer>(entity); AddComponent<BoonOffer>(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<BoonEffects>(entity);
AddComponent<DashTrailState>(entity);
} }
} }
} }
@@ -118,6 +118,30 @@ namespace ProjectM.Server
if (idx < 0) if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw 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<BoonEffects>(player))
return false; // real players are baked with it; skip defensively otherwise
var fx = state.EntityManager.GetComponentData<BoonEffects>(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<StatModifier>(player); var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier mods.Add(new StatModifier
{ {
@@ -58,16 +58,16 @@ namespace ProjectM.Server
return; return;
ref var pool = ref catalog.Value.Value; ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls) in foreach (var (offer, owner, region, cls, fx) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>>() SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
.WithAll<PlayerTag>()) .WithAll<PlayerTag>())
{ {
if (region.ValueRO.Region != RegionId.Expedition) if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing 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; 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 }; offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
} }
@@ -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
{
/// <summary>
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
/// living enemies within <see cref="k_Radius"/> 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 <see cref="DashSystem"/> (dash state committed) and before
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
///
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. 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 (&lt;~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).
/// </summary>
[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<NetworkTime>();
state.RequireForUpdate<DashTrailState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var nt = SystemAPI.GetSingleton<NetworkTime>();
var serverTick = nt.ServerTick;
if (!serverTick.IsValid)
return;
// Snapshot living enemies once (positions + radii + entities), stable query order.
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
var enemyRadii = new NativeList<float>(Allocator.Temp);
foreach (var (tx, hr, hp, te) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
.WithAll<EnemyTag>().WithNone<Dying>().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<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
RefRO<GhostOwner>, RefRO<BoonEffects>>()
.WithAll<PlayerTag, Simulate>())
{
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2a00802a81103745a1d20475a3c7b7b
@@ -77,6 +77,7 @@ namespace ProjectM.Server
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity); bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
uint negatedForThisEntity = 0u; uint negatedForThisEntity = 0u;
float total = 0f; 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++) for (int i = 0; i < dmg.Length; i++)
{ {
uint src = dmg[i].SourceTick; uint src = dmg[i].SourceTick;
@@ -96,6 +97,7 @@ namespace ProjectM.Server
} }
} }
total += dmg[i].Amount; 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 // 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. // whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
@@ -149,6 +151,8 @@ namespace ProjectM.Server
ecb.AddComponent(entity, new Dying ecb.AddComponent(entity, new Dying
{ {
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks), UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
KillerNetId = killerNetId, // Phase 1.7: KillRewardSystem reads this for Siphon/Frenzy
Rewarded = 0,
}); });
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup)); if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState)); if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
@@ -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
{
/// <summary>
/// Phase 1.7 on-kill boons. When <c>HealthApplyDamageSystem</c> stamps an enemy <see cref="Dying"/> it records the
/// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse:
/// <see cref="BoonFlag.Siphon"/> heals the killer (clamped to <see cref="EffectiveCharacterStats.MaxHealth"/>) and
/// <see cref="BoonFlag.Frenzy"/> refreshes a short cooldown-reduction buff (<see cref="TimedModifierUtil.Upsert"/> —
/// re-stamped, never stacked). Idempotent via the <see cref="Dying.Rewarded"/> latch (a value write, no edge-detect).
///
/// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW
/// <see cref="Health"/> access, which would alias that system's <c>RefRW&lt;Health&gt;</c> victim query. Here the
/// only query is <c>RefRW&lt;Dying&gt;</c> 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.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(HealthApplyDamageSystem))]
public partial struct KillRewardSystem : ISystem
{
ComponentLookup<BoonEffects> m_Fx;
ComponentLookup<Health> m_Health;
ComponentLookup<EffectiveCharacterStats> m_EffChar;
BufferLookup<StatModifier> m_Mods;
BufferLookup<TimedModifier> m_Timed;
const float k_SiphonHeal = 8f; // HP restored per kill (tunable)
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_Fx = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_Health = state.GetComponentLookup<Health>(isReadOnly: false);
m_EffChar = state.GetComponentLookup<EffectiveCharacterStats>(isReadOnly: true);
m_Mods = state.GetBufferLookup<StatModifier>(isReadOnly: false);
m_Timed = state.GetBufferLookup<TimedModifier>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<Dying>(); // only run while a fresh corpse exists
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().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<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().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<RefRW<Dying>>().WithAll<EnemyTag>().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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43348399863cc454a8752cce54cc329d
@@ -24,12 +24,18 @@ namespace ProjectM.Server
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits); /// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets. /// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
/// ///
/// Phase 1.7 mechanic-changer boons ride the server-only <see cref="ProjectileEffectState"/> (seeded at
/// spawn by <c>AbilityFireSystem</c> from the owner's <see cref="BoonEffects"/>): PIERCE lets the projectile
/// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated) <see cref="Projectile.Direction"/>
/// 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 <see cref="DamageEvent"/> to the target (consumed by /// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
/// <c>HealthApplyDamageSystem</c>) and destroys the projectile. Deferring damage to a buffer lets a /// <c>HealthApplyDamageSystem</c>). Deferring damage to a buffer lets a single tick stack hits from multiple
/// single tick stack hits from multiple projectiles. All structural changes go through an /// projectiles. All structural changes go through an <see cref="EntityCommandBuffer"/> that plays back
/// <see cref="EntityCommandBuffer"/> that plays back immediately to the /// immediately to the <see cref="EntityManager"/> (Temp allocator).
/// <see cref="EntityManager"/> (Temp allocator) — keeping this server-only, once-per-tick system
/// self-contained and plain-world testable without a separate ECB system.
/// </summary> /// </summary>
[BurstCompile] [BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [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.</summary> /// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
ComponentLookup<BossState> m_BossLookup; ComponentLookup<BossState> m_BossLookup;
/// <summary>RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set.</summary>
ComponentLookup<ProjectileEffectState> m_FxLookup;
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary> /// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
const float k_ProjectileRadius = 0.2f; const float k_ProjectileRadius = 0.2f;
/// <summary>Max planar distance a Ricochet chain will reach for its next target (tunable).</summary>
const float k_ChainRange = 8f;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true); m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false); m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true); m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate<Projectile>(); state.RequireForUpdate<Projectile>();
@@ -69,6 +80,7 @@ namespace ProjectM.Server
m_GhostOwnerLookup.Update(ref state); m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state); m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state); m_BossLookup.Update(ref state);
m_FxLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt); bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
@@ -92,11 +104,14 @@ namespace ProjectM.Server
} }
foreach (var (xform, proj, owner, projectileEntity) in foreach (var (xform, proj, owner, projectileEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>, RefRO<GhostOwner>>() SystemAPI.Query<RefRO<LocalTransform>, RefRW<Projectile>, RefRO<GhostOwner>>()
.WithEntityAccess()) .WithEntityAccess())
{ {
int projOwnerId = owner.ValueRO.NetworkId; 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 // This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets. // than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
float3 cur = xform.ValueRO.Position; float3 cur = xform.ValueRO.Position;
@@ -117,6 +132,11 @@ namespace ProjectM.Server
m_GhostOwnerLookup[target].NetworkId == projOwnerId) m_GhostOwnerLookup[target].NetworkId == projOwnerId)
continue; 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); float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
// Closest point on the travel segment to the target centre. // Closest point on the travel segment to the target centre.
@@ -135,23 +155,68 @@ namespace ProjectM.Server
if (bestIdx >= 0) if (bestIdx >= 0)
{ {
// Earliest target along the travel path: deal damage and consume the projectile. var hitTarget = targetEntities[bestIdx];
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
// Earliest target along the travel path: deal damage.
ecb.AppendToBuffer(hitTarget, new DamageEvent
{ {
Amount = proj.ValueRO.Damage, Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId, SourceNetworkId = projOwnerId,
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u, 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)) 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 m_KnockbackLookup[hitTarget] = new KnockbackState
{ {
Dir = proj.ValueRO.Direction, Dir = kdir,
Speed = Tuning.KnockbackSpeed, Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), 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); ecb.DestroyEntity(projectileEntity);
continue; continue;
} }
@@ -168,5 +233,38 @@ namespace ProjectM.Server
targetPositions.Dispose(); targetPositions.Dispose();
targetRadii.Dispose(); targetRadii.Dispose();
} }
/// <summary>True when <paramref name="target"/> is already in the projectile's re-hit history.</summary>
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;
}
/// <summary>Nearest living target to <paramref name="from"/> within <see cref="k_ChainRange"/> that is neither
/// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1.</summary>
int FindChainTarget(in NativeList<Entity> targetEntities, in NativeList<float3> 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;
}
} }
} }
@@ -389,14 +389,18 @@ case RunLifecycle.RouteSelect:
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the // 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 // effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
// disjoint and survive. Idempotent — safe on every Returning tick. // disjoint and survive. Idempotent — safe on every Returning tick.
foreach (var (mods, offer) in foreach (var (mods, timed, fx, offer) in
SystemAPI.Query<DynamicBuffer<StatModifier>, RefRW<BoonOffer>>().WithAll<PlayerTag>()) SystemAPI.Query<DynamicBuffer<StatModifier>, DynamicBuffer<TimedModifier>, RefRW<BoonEffects>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
{ {
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan); Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase, TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too 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; offer.ValueRW = default;
} }
@@ -21,6 +21,13 @@ namespace ProjectM.Simulation
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and /// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
/// predicted + server projectiles match (both folded the same replicated modifiers). /// predicted + server projectiles match (both folded the same replicated modifiers).
/// ///
/// Phase 1.7 mechanic-changer boons ride the owner-replicated <see cref="BoonEffects"/> (SendToOwner, so the
/// predicting owner has it — the .WithAll&lt;Simulate&gt;() 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 <c>Fork</c> 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 <see cref="ProjectileEffectState"/> at spawn (resolved by ProjectileDamageSystem).
///
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all /// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
/// non-idempotent effects (spawning, cooldown advance) are gated behind /// non-idempotent effects (spawning, cooldown advance) are gated behind
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute /// 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. // C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
ComponentLookup<KnockbackState> m_KnockbackLookup; ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup; ComponentLookup<BossState> m_BossLookup;
// Phase 1.7: owner-replicated mechanic-changer boons, read by the player entity (query is at the 7-type cap).
ComponentLookup<BoonEffects> m_BoonEffectsLookup;
/// <summary>~9° gap between adjacent Split-Shot projectiles (tunable).</summary>
const float k_ForkSpreadRad = 0.157f;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
@@ -48,6 +60,7 @@ namespace ProjectM.Simulation
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false); m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true); m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
} }
[BurstCompile] [BurstCompile]
@@ -71,6 +84,7 @@ namespace ProjectM.Simulation
bool isServer = state.WorldUnmanaged.IsServer(); bool isServer = state.WorldUnmanaged.IsServer();
m_KnockbackLookup.Update(ref state); m_KnockbackLookup.Update(ref state);
m_BossLookup.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 // 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. // 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 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 // 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 // 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. // 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 // 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). // (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], KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed, 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); uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -195,11 +214,25 @@ namespace ProjectM.Simulation
candidates); 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
for (int s = 0; s < shots; s++)
{
// 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); var projectile = ecb.Instantiate(prefab);
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
float3 planarDir = new float3(dir.x, 0f, dir.y);
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f; float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
spawnPos.y = xform.ValueRO.Position.y; spawnPos.y = xform.ValueRO.Position.y;
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up()); quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
@@ -210,13 +243,21 @@ namespace ProjectM.Simulation
// identically on both worlds), so the move/damage systems need no modifier lookup. // identically on both worlds), so the move/damage systems need no modifier lookup.
ecb.SetComponent(projectile, new Projectile ecb.SetComponent(projectile, new Projectile
{ {
Direction = math.normalize(dir), Direction = sdir,
SpawnId = spawnId, SpawnId = spawnId,
Speed = eff.ValueRO.ProjectileSpeed, Speed = eff.ValueRO.ProjectileSpeed,
Damage = eff.ValueRO.Damage, Damage = eff.ValueRO.Damage,
Range = eff.ValueRO.Range, Range = eff.ValueRO.Range,
DistanceTravelled = 0f, 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. // Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks); uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -4,25 +4,48 @@ using Unity.Entities;
namespace ProjectM.Simulation namespace ProjectM.Simulation
{ {
/// <summary> /// <summary>
/// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline /// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (<see cref="Kind"/>==0
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row /// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row, the
/// (bytes, never enums, on the baked path). <see cref="Id"/> is the stable APPEND-ONLY key the replicated /// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
/// <c>BoonOffer</c> options and pick RPC carry. <see cref="Weight"/> is the rarity draw weight /// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
/// (common 100 / rare 30 / epic 10). <see cref="ClassMask"/> gates by class: bit0 = Warrior (classId 0), /// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
/// bit1 = Ranger (classId 1), 3 = both. /// <c>BoonOffer</c> options + pick RPC carry. <see cref="Weight"/> is the rarity draw weight (common 100 /
/// uncommon 60 / rare 30 / epic 10). <see cref="ClassMask"/>: bit0 = Warrior (classId 0), bit1 = Ranger
/// (classId 1), 3 = both. <see cref="Family"/> tags synergy/dedup — no two same-family options in one deal.
/// </summary> /// </summary>
public struct BoonDefBlob public struct BoonDefBlob
{ {
public byte Id; public byte Id;
public byte Target; // StatTarget as byte public byte Target; // StatTarget as byte (Kind==0)
public byte Op; // ModOp as byte public byte Op; // ModOp as byte (Kind==0)
public float Value; public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
public byte Weight; public byte Weight;
public byte ClassMask; 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 FixedString64Bytes Name;
public FixedString128Bytes Desc; public FixedString128Bytes Desc;
} }
/// <summary>
/// Synergy/dedup tags for <see cref="BoonDefBlob.Family"/>. 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).
/// </summary>
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;
}
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary> /// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
public struct BoonCatalogBlob public struct BoonCatalogBlob
{ {
@@ -45,8 +68,10 @@ namespace ProjectM.Simulation
} }
/// <summary> /// <summary>
/// Pure, deterministic boon selection math — integer-hash only (<see cref="RunMapMath.Hash(uint,uint)"/> chain, /// Pure, deterministic boon selection math — integer-hash only (<c>RunMapMath.Hash</c> chain, no RNG state), so
/// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested. /// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is
/// safe because <c>BoonOfferSystem</c> draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch)
/// — the client never re-runs it. EditMode-tested.
/// </summary> /// </summary>
public static class BoonMath public static class BoonMath
{ {
@@ -54,24 +79,35 @@ namespace ProjectM.Simulation
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1)); public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
/// <summary> /// <summary>
/// Draw 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per /// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// <paramref name="offerSeed"/>. If the class-legal pool has fewer than 3 entries the tail repeats the /// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
/// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids. /// already owns is excluded (dedup); no two options share a <see cref="BoonFamily"/> 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).
/// </summary> /// </summary>
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) out byte o0, out byte o1, out byte o2)
{ {
byte classBit = MaskFor(classId); byte classBit = MaskFor(classId);
int ownedFamilies = OwnedFamilyMask(owned);
// Class-legal candidate indices + the total weight. var candidates = new FixedList128Bytes<byte>(); // catalog indices
var candidates = new FixedList128Bytes<byte>(); var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
int totalWeight = 0; int totalWeight = 0;
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++) 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].ClassMask & classBit) == 0) continue;
if (pool.Defs[i].Weight == 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); candidates.Add((byte)i);
totalWeight += pool.Defs[i].Weight; weights.Add((byte)w);
totalWeight += w;
} }
o0 = o1 = o2 = 0; o0 = o1 = o2 = 0;
@@ -79,38 +115,39 @@ namespace ProjectM.Simulation
return 0; return 0;
var picked = new FixedList32Bytes<byte>(); // picked catalog indices var picked = new FixedList32Bytes<byte>(); // picked catalog indices
var pickedFamilies = new FixedList32Bytes<byte>(); // families used this deal (fam != 0)
uint salt = 0; uint salt = 0;
while (picked.Length < 3 && picked.Length < candidates.Length) 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; 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; int acc = 0;
for (int c = 0; c < candidates.Length; c++) for (int c = 0; c < candidates.Length; c++)
{ {
acc += pool.Defs[candidates[c]].Weight; acc += weights[c];
if (roll < (uint)acc) { drawn = candidates[c]; break; } if (roll < (uint)acc) { chosen = c; break; }
} }
byte drawn = candidates[chosen];
byte fam = pool.Defs[drawn].Family;
bool dup = false; bool dup = false;
for (int p = 0; p < picked.Length; p++) for (int p = 0; p < picked.Length; p++)
if (picked[p] == drawn) { dup = true; break; } 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); picked.Add(drawn);
if (fam != 0) pickedFamilies.Add(fam);
salt = 0; salt = 0;
} }
else if (++salt > 16) else if (++salt > 16)
{ {
// Rejection budget spent — take the first unpicked candidate (still deterministic). // Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
for (int c = 0; c < candidates.Length; c++) AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
{
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; }
}
salt = 0; salt = 0;
} }
} }
@@ -121,6 +158,63 @@ namespace ProjectM.Simulation
return picked.Length; return picked.Length;
} }
/// <summary>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).</summary>
static void AddFallback(ref FixedList32Bytes<byte> picked, ref FixedList32Bytes<byte> pickedFamilies,
in FixedList128Bytes<byte> 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);
}
/// <summary>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.</summary>
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;
}
}
/// <summary>Bitmask (indexed by <see cref="BoonFamily"/> 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).</summary>
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;
}
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary> /// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
public static int FindDef(ref BoonCatalogBlob pool, byte id) public static int FindDef(ref BoonCatalogBlob pool, byte id)
{ {
@@ -131,8 +225,9 @@ namespace ProjectM.Simulation
} }
/// <summary> /// <summary>
/// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the /// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
/// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids. /// (<see cref="BoonDefBlob.Kind"/>==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).
/// </summary> /// </summary>
public static class BoonCatalogData public static class BoonCatalogData
{ {
@@ -143,25 +238,28 @@ namespace ProjectM.Simulation
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>(); ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, 12); var defs = builder.Allocate(ref root.Defs, 12);
int i = 0; int i = 0;
// id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc // ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage"); // (the Warrior's Fire is a cone, not a projectile). ----
defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown"); defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range"); defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed"); defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health"); defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage"); defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach"); defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed"); defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range"); defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown"); // ---- 4 strong flat-stat boons (Kind=0) ----
defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health"); defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage");
defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "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<BoonCatalogBlob>(allocator); var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
builder.Dispose(); builder.Dispose();
return blob; return blob;
} }
static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, /// <summary>A flat-stat boon row (Kind=0 — appends a <see cref="StatModifier"/>).</summary>
static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family,
string name, string desc) string name, string desc)
{ {
return new BoonDefBlob return new BoonDefBlob
@@ -172,6 +270,30 @@ namespace ProjectM.Simulation
Value = value, Value = value,
Weight = weight, Weight = weight,
ClassMask = mask, ClassMask = mask,
Kind = 0,
EffectKind = BoonEffectKind.None,
Family = family,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
/// <summary>A mechanic-changer boon row (Kind=1 — mutates <see cref="BoonEffects"/>). <paramref name="value"/>
/// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects.</summary>
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), Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc), Desc = new FixedString128Bytes(desc),
}; };
@@ -0,0 +1,61 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat
/// <see cref="StatModifier"/> band. Stackable counts (<see cref="Pierce"/>/<see cref="Fork"/>/<see cref="Chain"/>)
/// and boolean <see cref="Flags"/> (see <see cref="BoonFlag"/>) that combat systems read to alter behaviour.
/// <para>
/// Replicated <see cref="SendToOwnerType.SendToOwner"/> (matching <c>BoonOffer</c>): rollback-correctness is
/// provided by the <c>[GhostField]</c>s themselves — the owner is the sole predicting client and needs the
/// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in <c>AbilityFireSystem</c>, which
/// filters <c>.WithAll&lt;Simulate&gt;()</c>) 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 <see cref="SendToOwnerType.All"/> — the send type is not what enables rollback, the
/// <c>[GhostField]</c> is.
/// </para>
/// Baked INERT (all 0) on the player prefab (the <c>BoonOffer</c> idiom) so a pick is a non-structural mutate;
/// zeroed on the Returning edge in <c>RunDirectorSystem</c> alongside the StatModifier band strips.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
public struct BoonEffects : IComponentData
{
/// <summary>Extra enemy hits a projectile survives before despawning (stacks).</summary>
[GhostField] public byte Pierce;
/// <summary>Extra spread projectiles spawned per shot (stacks).</summary>
[GhostField] public byte Fork;
/// <summary>Targets a projectile chains to after a hit (stacks).</summary>
[GhostField] public byte Chain;
/// <summary>Boolean effect bits — see <see cref="BoonFlag"/>.</summary>
[GhostField] public byte Flags;
}
/// <summary>Bit masks for <see cref="BoonEffects.Flags"/>. Plain byte consts (never an enum compared in Burst).</summary>
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
}
/// <summary>
/// Stable byte discriminator for a <c>BoonDefBlob</c> mechanic-changer effect (0 = a plain stat boon).
/// Bytes only — Burst-safe, never an enum compared inside a Bursted system.
/// </summary>
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 76c925707efba46478fb9c697d391e0d
@@ -15,5 +15,13 @@ namespace ProjectM.Simulation
{ {
/// <summary>Server tick the corpse despawns (via <c>TickUtil.NonZero</c>; compared via <c>NetworkTick</c>).</summary> /// <summary>Server tick the corpse despawns (via <c>TickUtil.NonZero</c>; compared via <c>NetworkTick</c>).</summary>
public uint UntilTick; public uint UntilTick;
/// <summary>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 <c>KillRewardSystem</c> for on-kill boons (Siphon/Frenzy).</summary>
public int KillerNetId;
/// <summary>Phase 1.7: 0 until <c>KillRewardSystem</c> has granted this corpse's on-kill rewards (idempotent
/// value latch — no structural change, no edge-detection).</summary>
public byte Rewarded;
} }
} }
@@ -15,13 +15,14 @@ namespace ProjectM.Simulation
static class KnockbackUtil static class KnockbackUtil
{ {
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup, public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> 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)) if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
return; return;
float3 delta = targetPos - sourcePos; float3 delta = targetPos - sourcePos;
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback; 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 }; lookup[target] = new KnockbackState { Dir = dir, Speed = speed, UntilTick = untilTick };
} }
} }
@@ -0,0 +1,35 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1.7 per-projectile mechanic-changer state — SERVER-ONLY, NOT a <c>[GhostField]</c> (mirrors
/// <see cref="KnockbackState"/>): it adds no replicated surface, so the <see cref="Projectile"/> ghost hash
/// stays FROZEN (adding fields to the ghost <see cref="Projectile"/> 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 (<c>AbilityFireSystem</c>) from the owner's
/// <see cref="BoonEffects"/>, and read only by <c>ProjectileDamageSystem</c> (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 <see cref="Projectile.Direction"/>; pull just flips the
/// server-stamped <see cref="KnockbackState.Dir"/>).
/// </summary>
public struct ProjectileEffectState : IComponentData
{
/// <summary>Enemy hits remaining before the projectile despawns (0 = destroy on next hit).</summary>
public byte PierceRemaining;
/// <summary>Chain-to-next-target hops remaining after a hit.</summary>
public byte ChainRemaining;
/// <summary>bit0 = Pull (stamp knockback TOWARD the shooter instead of away).</summary>
public byte Flags;
/// <summary>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).</summary>
public FixedList64Bytes<Entity> Hit;
}
/// <summary>Bit masks for <see cref="ProjectileEffectState.Flags"/>.</summary>
public static class ProjectileEffectFlag
{
public const byte Pull = 1;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b54e702af1501ec408b0b09e23853f63
@@ -42,5 +42,32 @@ namespace ProjectM.Simulation
if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; } if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; }
return removed; return removed;
} }
/// <summary>Phase 1.7: guarantee EXACTLY ONE row per <paramref name="sourceId"/> in BOTH the replicated
/// <see cref="StatModifier"/> buffer and this server-only <see cref="TimedModifier"/> buffer (remove-then-add) so a
/// timed buff REFRESHES (re-stamps <paramref name="untilTick"/>) rather than stacking on a repeat grant. Used by
/// <c>KillRewardSystem</c> for Frenzy so successive kills extend the surge instead of compounding the modifier.</summary>
public static void Upsert(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> 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 });
}
/// <summary>Phase 1.7: remove every server-only <see cref="TimedModifier"/> row matching <paramref name="sourceId"/>
/// (the paired <see cref="StatModifier"/> 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.</summary>
public static int RemoveBySourceId(DynamicBuffer<TimedModifier> 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;
}
} }
} }
@@ -0,0 +1,21 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a <c>[GhostField]</c>, so no ghost-hash impact;
/// it piggybacks the <see cref="BoonEffects"/> player re-bake). Keys the per-dash "hit once" dedup to
/// <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and cannot be relied
/// upon to reset) rather than to any DashState clear edge: <c>DashTrailDamageSystem</c> clears <see cref="Hit"/>
/// whenever the current StartTick differs from <see cref="LastStartTick"/>. Server-only (no rollback) so the
/// accumulator is safe to persist across ticks.
/// </summary>
public struct DashTrailState : IComponentData
{
/// <summary>The <see cref="DashState.StartTick"/> the <see cref="Hit"/> set currently belongs to.</summary>
public uint LastStartTick;
/// <summary>Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash).</summary>
public FixedList64Bytes<Entity> Hit;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 248bd87d96cfc5b43b4681a203756c5c
@@ -20,6 +20,9 @@ namespace ProjectM.Simulation
public int OwnerId; public int OwnerId;
public uint Stamp; public uint Stamp;
public uint KnockUntil; 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
} }
/// <summary> /// <summary>
@@ -54,6 +57,10 @@ namespace ProjectM.Simulation
ComponentLookup<RegionTag> m_RegionLookup; ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup; BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup; BufferLookup<StatModifier> m_StatModLookup;
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
const float k_DetonateRadius = 3.5f;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
@@ -64,6 +71,7 @@ namespace ProjectM.Simulation
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true); m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false); m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true); m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
} }
@@ -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 // 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). // when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default; var cleaves = isServer ? new NativeList<PendingCleave>(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 foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>, SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
@@ -147,6 +156,7 @@ namespace ProjectM.Simulation
bool isFin = swingStep >= comboLen; bool isFin = swingStep >= comboLen;
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the // 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. // 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); bool hasMods = m_StatModLookup.HasBuffer(entity);
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage); 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); 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, OwnerId = owner.ValueRO.NetworkId,
Stamp = stamp, Stamp = stamp,
KnockUntil = knockUntil, 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) if (c.KnockSpeed > 0f)
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target, 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 // 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. // Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining.
for (int s = 0; s < cleaves.Length; s++) for (int s = 0; s < cleaves.Length; s++)
+14 -1
View File
@@ -208,7 +208,7 @@ namespace ProjectM.Simulation
// inline mods share that one id and are stripped target-agnostically via // inline mods share that one id and are stripped target-agnostically via
// TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT):
// 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; // 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). // 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots).
/// <summary>Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase + /// <summary>Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase +
@@ -219,6 +219,19 @@ namespace ProjectM.Simulation
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary> /// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
public const uint BoonSourceIdSpan = 0x10000u; public const uint BoonSourceIdSpan = 0x10000u;
/// <summary>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.</summary>
public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF
/// <summary>Phase 1.7: Frenzy surge duration (ticks, ~60/s) re-stamped on each kill.</summary>
public const int FrenzyDurationTicks = 240;
/// <summary>Phase 1.7: Frenzy cooldown modifier (PercentMult on CooldownTicks; -0.30 = 30% faster abilities).</summary>
public const float FrenzyCooldownMult = -0.30f;
/// <summary>DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon /// <summary>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 /// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live
/// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons.</summary> /// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons.</summary>
@@ -11,10 +11,13 @@ using System.Collections.Generic;
namespace ProjectM.Tests namespace ProjectM.Tests
{ {
/// <summary> /// <summary>
/// Pins the two-channel boon lifecycle: <see cref="BoonApplySystem"/> (a valid pick appends exactly ONE /// Pins the two-channel boon lifecycle (Phase 1.7 table). <see cref="BoonApplySystem"/>: a valid STAT pick appends
/// boon-band <see cref="StatModifier"/> and clears Pending; out-of-range / not-pending / closed-lifecycle picks /// exactly ONE boon-band <see cref="StatModifier"/> and clears Pending; a MECHANIC-CHANGER pick mutates
/// are rejected; the grace auto-pick deals Option0) and the RunDirector Returning-edge RANGE STRIP (every /// <see cref="BoonEffects"/> (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected;
/// boon-band row dies; class/meta/equip bands survive; offers zeroed) — run boons NEVER persist (DR-037). /// the grace auto-pick deals Option0. The RunDirector Returning-edge strip: every boon-band StatModifier dies,
/// BoonEffects is zeroed, the Frenzy timed row is removed from BOTH buffers, and class/meta/equip bands survive.
/// New default table ids: 1 Piercing (effect), 4 Detonating (effect), 9 Executioner (Damage +50%),
/// 10 Titan (MaxHealth +60), 11 Fleet Foot (MoveSpeed +18%).
/// </summary> /// </summary>
public class BoonApplyTests public class BoonApplyTests
{ {
@@ -48,9 +51,11 @@ namespace ProjectM.Tests
return (world, group, dir, catalog); return (world, group, dir, catalog);
} }
static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5) // Defaults to STAT ids so an accepted pick appends a StatModifier row (o1 = 11 Fleet Foot).
static Entity MakePicker(EntityManager em, int netId, byte o0 = 9, byte o1 = 11, byte o2 = 10)
{ {
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag)); var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag),
typeof(BoonEffects));
em.AddBuffer<StatModifier>(e); em.AddBuffer<StatModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId }); em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition }); em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
@@ -78,14 +83,14 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void ValidPick_AppendsBoonBandRow_AndClearsPending() public void ValidStatPick_AppendsBoonBandRow_AndClearsPending()
{ {
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
var player = MakePicker(em, 1); 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(); group.Update();
@@ -93,12 +98,31 @@ namespace ProjectM.Tests
var mods = em.GetBuffer<StatModifier>(player); var mods = em.GetBuffer<StatModifier>(player);
Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target"); Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target");
Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op); Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op);
Assert.AreEqual(0.12f, mods[0].Value, 1e-4f); Assert.AreEqual(0.18f, mods[0].Value, 1e-4f);
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed"); Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced"); Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced");
} }
} }
[Test]
public void EffectPick_MutatesBoonEffects_AppendsNoStatRow()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var player = MakePicker(em, 1, o0: 1); // Option0 = id 1 (Piercing Shots — a mechanic-changer)
SendPick(em, 1, index: 0);
group.Update();
Assert.AreEqual(0, BoonRows(em, player), "a mechanic-changer appends NO StatModifier row");
Assert.AreEqual(1, em.GetComponentData<BoonEffects>(player).Pierce, "Pierce incremented");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(0u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "no band row → counter unchanged");
}
}
[Test] [Test]
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean() public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
{ {
@@ -106,7 +130,7 @@ namespace ProjectM.Tests
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward); var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
_worlds.Add(w1); _worlds.Add(w1);
var p1 = MakePicker(w1.EntityManager, 1); 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); SendPick(w1.EntityManager, 1, 0);
g1.Update(); g1.Update();
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected"); Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
@@ -129,7 +153,7 @@ namespace ProjectM.Tests
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth) var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 MaxHealth)
var run = em.GetComponentData<RunRuntime>(dir); var run = em.GetComponentData<RunRuntime>(dir);
run.RewardGraceTick = T0 - 10; // already elapsed run.RewardGraceTick = T0 - 10; // already elapsed
em.SetComponentData(dir, run); em.SetComponentData(dir, run);
@@ -144,9 +168,10 @@ namespace ProjectM.Tests
} }
[Test] [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"); var world = new World("BoonStripTest");
using (world) using (world)
{ {
@@ -163,24 +188,29 @@ namespace ProjectM.Tests
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 }); em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer), 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, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity); em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 }); em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy });
var mods = em.AddBuffer<StatModifier>(player); var mods = em.AddBuffer<StatModifier>(player);
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.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 = 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 = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band)
mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip
var timed = em.AddBuffer<TimedModifier>(player);
timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 });
group.Update(); // Returning: strip + bank + home -> Staging group.Update(); // Returning: strip + bank + home -> Staging
var after = em.GetBuffer<StatModifier>(player); var after = em.GetBuffer<StatModifier>(player);
Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive"); Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, three permanent bands survive");
for (int i = 0; i < after.Length; i++) for (int i = 0; i < after.Length; i++)
Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase
&& after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor"); && after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor");
Assert.AreEqual(0, em.GetBuffer<TimedModifier>(player).Length, "Frenzy timed row stripped");
Assert.AreEqual(default(BoonEffects), em.GetComponentData<BoonEffects>(player), "mechanic-changer effects zeroed");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed"); Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle); Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
} }
@@ -25,14 +25,14 @@ namespace ProjectM.Tests
{ {
for (uint seed = 1; seed < 200; seed += 7) 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.AreEqual(3, n, "the default pool always fills 3 options");
Assert.AreNotEqual(a0, a1, "distinct"); Assert.AreNotEqual(a0, a1, "distinct");
Assert.AreNotEqual(a1, a2, "distinct"); Assert.AreNotEqual(a1, a2, "distinct");
Assert.AreNotEqual(a0, a2, "distinct"); Assert.AreNotEqual(a0, a2, "distinct");
// Deterministic re-draw. // 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(a0, b0);
Assert.AreEqual(a1, b1); Assert.AreEqual(a1, b1);
Assert.AreEqual(a2, b2); Assert.AreEqual(a2, b2);
@@ -73,7 +73,7 @@ namespace ProjectM.Tests
Entity MakePlayer(int netId, byte region, byte classId) Entity MakePlayer(int netId, byte region, byte classId)
{ {
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), 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 GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = region }); em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, new PlayerClass { ClassId = classId }); em.SetComponentData(e, new PlayerClass { ClassId = classId });
@@ -102,5 +102,44 @@ namespace ProjectM.Tests
} }
} }
static byte FamilyOf(ref BoonCatalogBlob pool, byte id)
{
int idx = BoonMath.FindDef(ref pool, id);
return idx >= 0 ? pool.Defs[idx].Family : (byte)0;
}
[Test]
public void PickBoons_NeverOffersTwoSameFamily_InOneDeal()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
for (byte classId = 0; classId <= 1; classId++)
for (uint seed = 1; seed < 200; seed += 3)
{
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
byte f0 = FamilyOf(ref pool, a0), f1 = FamilyOf(ref pool, a1), f2 = FamilyOf(ref pool, a2);
Assert.AreNotEqual(f0, f1, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f1, f2, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f0, f2, "dominated-offer protection: no two same-family options in one deal");
}
blob.Dispose();
}
[Test]
public void PickBoons_ExcludesOwnedNonStackingFlag()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
var owned = new BoonEffects { Flags = BoonFlag.DashTrail }; // already own Blade Dash (id 5, both classes)
for (byte classId = 0; classId <= 1; classId++)
for (uint seed = 1; seed < 300; seed += 3)
{
BoonMath.PickBoons(seed, classId, owned, ref pool, out byte a0, out byte a1, out byte a2);
Assert.IsFalse(a0 == 5 || a1 == 5 || a2 == 5, "an owned non-stacking flag boon (Blade Dash) is never re-offered");
}
blob.Dispose();
} }
} }
}
@@ -0,0 +1,94 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities tests for <see cref="DashTrailDamageSystem"/> (Phase 1.7 Blade Dash). A dashing player with the
/// boon damages a nearby enemy ONCE per dash (StartTick-keyed dedup survives a re-tick); a fresh dash hits again;
/// no boon → no damage.
/// </summary>
public class DashTrailDamageSystemTests
{
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick)
{
var world = new World("DashTrailTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashTrailDamageSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
em.SetComponentData(em.CreateEntity(typeof(NetworkTime)), new NetworkTime { ServerTick = new NetworkTick(tick) });
return (world, group, em);
}
static void SetTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakeDasher(EntityManager em, byte flags, uint startTick, uint iframeUntil)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects), typeof(DashTrailState));
em.AddComponentData(e, LocalTransform.FromPosition(new float3(0f, 0f, 0f)));
em.AddComponentData(e, new DashState { Dir = new float2(1f, 0f), StartTick = startTick, IFrameUntilTick = iframeUntil, RecoverUntilTick = iframeUntil + 9 });
em.AddComponent<Simulate>(e); // enabled by default
em.SetComponentData(e, new GhostOwner { NetworkId = 1 });
em.SetComponentData(e, new BoonEffects { Flags = flags });
return e;
}
static Entity MakeEnemy(EntityManager em, float3 pos)
{
var e = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = 0.5f });
em.AddComponentData(e, new Health { Current = 60f, Max = 60f });
em.AddBuffer<DamageEvent>(e);
return e;
}
[Test]
public void BladeDash_DamagesNearbyEnemy_OncePerDash_ReHitsOnNextDash()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
var player = MakeDasher(em, BoonFlag.DashTrail, startTick: 100, iframeUntil: 112);
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); // within 1.6 + 0.5
group.Update(); // tick 100, dashing
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "enemy in the dash path takes one hit");
group.Update(); // same tick + same StartTick -> dedup, no second hit
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "no re-hit within the same dash");
// A fresh dash (new StartTick) resets the dedup set -> the enemy can be hit again.
SetTick(em, 130);
em.SetComponentData(player, new DashState { Dir = new float2(1f, 0f), StartTick = 130, IFrameUntilTick = 142, RecoverUntilTick = 151 });
group.Update();
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length, "a fresh dash hits the enemy again");
}
}
[Test]
public void NoBoon_NoDamage()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
MakeDasher(em, flags: 0, startTick: 100, iframeUntil: 112); // no DashTrail flag
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no Blade Dash boon -> no trail damage");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7603c5c6b91bb854d8b88739bdb6f4b1
@@ -0,0 +1,128 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities tests for <see cref="KillRewardSystem"/> (Phase 1.7 on-kill boons). Siphon heals the credited
/// killer (clamped to their effective max, once per corpse via the Dying.Rewarded latch); Frenzy upserts a single
/// cooldown-reduction row; an unresolved killer (KillerNetId &lt; 0) grants nothing but is still latched.
/// </summary>
public class KillRewardSystemTests
{
const uint T0 = 5000;
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
{
var world = new World("KillRewardTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<KillRewardSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
return (world, group, em);
}
static Entity MakeKiller(EntityManager em, int netId, byte flags, float hp, float maxHp)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects),
typeof(Health), typeof(EffectiveCharacterStats));
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new BoonEffects { Flags = flags });
em.SetComponentData(e, new Health { Current = hp, Max = maxHp });
em.SetComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHp });
return e;
}
static Entity MakeCorpse(EntityManager em, int killerNetId)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(e, new Dying { UntilTick = T0 + 50, KillerNetId = killerNetId, Rewarded = 0 });
return e;
}
static int FrenzyRows(EntityManager em, Entity player)
{
var mods = em.GetBuffer<StatModifier>(player);
int n = 0;
for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == Tuning.FrenzySourceId) n++;
return n;
}
[Test]
public void Siphon_HealsKiller_ClampedToMax_OncePerCorpse()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.Greater(em.GetComponentData<Health>(killer).Current, 50f, "Siphon healed the killer");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "corpse latched as rewarded");
float afterFirst = em.GetComponentData<Health>(killer).Current;
group.Update(); // second tick: Rewarded==1 -> no double-heal
Assert.AreEqual(afterFirst, em.GetComponentData<Health>(killer).Current, 1e-4f, "no double-heal on a re-tick");
}
}
[Test]
public void Siphon_DoesNotOverheal_AboveEffectiveMax()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 128f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(130f, em.GetComponentData<Health>(killer).Current, 1e-4f, "heal clamps to the effective max");
}
}
[Test]
public void Frenzy_UpsertsSingleCooldownRow()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Frenzy, hp: 100f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "one Frenzy StatModifier row");
// A second corpse (new kill) re-stamps rather than stacking.
var c2 = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(c2, new Dying { UntilTick = T0 + 60, KillerNetId = 1, Rewarded = 0 });
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "Frenzy refreshes, never stacks");
}
}
[Test]
public void UnresolvedKiller_GrantsNothing_ButLatches()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon | BoonFlag.Frenzy, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: -1); // environment/AoE kill — no credit
group.Update();
Assert.AreEqual(50f, em.GetComponentData<Health>(killer).Current, 1e-4f, "no heal for an uncredited kill");
Assert.AreEqual(0, FrenzyRows(em, killer), "no Frenzy for an uncredited kill");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "still latched so it is not reprocessed");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e10a4fa71c531a42b093a1b43d1ccaf
@@ -147,5 +147,65 @@ namespace ProjectM.Tests
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage."); Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage.");
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed."); Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
} }
static Entity MakeProjectileFx(EntityManager em, float3 pos, float2 dir, float speed, float damage,
float range, float distanceTravelled, int ownerId, byte pierce, byte chain, byte flags = 0)
{
var e = MakeProjectile(em, pos, dir, speed, damage, range, distanceTravelled, ownerId);
em.AddComponentData(e, new ProjectileEffectState { PierceRemaining = pierce, ChainRemaining = chain, Flags = flags });
return e;
}
[Test]
public void Pierce_SurvivesFirstTarget_HitsSecond_NeverReHitsFirst()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f);
var b = MakeTarget(em, new float3(0f, 0f, 6f), hitRadius: 0.8f, health: 60f);
// Post-move at z=6; the swept segment [z=0 -> z=6] (speed*dt = 6) covers both; A (z=3) is earliest.
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 1, chain: 0);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "earliest target A is hit");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "only the earliest target is hit per tick");
Assert.IsTrue(em.Exists(proj), "pierce lets the projectile survive the first hit");
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).PierceRemaining, "pierce consumed");
Tick(world, group, 0.1f); // same position: A now excluded by the hit-set -> B is the earliest
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A must NOT be re-hit (hit-set exclusion)");
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(b).Length, "B hit on the second pass");
Assert.IsFalse(em.Exists(proj), "with pierce spent, the second hit consumes the projectile");
}
[Test]
public void Chain_RetargetsTowardNextEnemy_AfterHit()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); // on-axis, hit first
var b = MakeTarget(em, new float3(2f, 0f, 6f), hitRadius: 0.8f, health: 60f); // off-axis, chain target
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 0, chain: 1);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A (on the path) is hit");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "B is off the path, not hit by the sweep this tick");
Assert.IsTrue(em.Exists(proj), "chain lets the projectile survive to seek the next enemy");
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).ChainRemaining, "chain consumed");
var dir = em.GetComponentData<Projectile>(proj).Direction;
Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)");
} }
} }
}
@@ -38,5 +38,26 @@ namespace ProjectM.Tests
Assert.DoesNotThrow(() => group.SortSystems(), Assert.DoesNotThrow(() => group.SortSystems(),
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation."); "A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
} }
[Test]
public void PredictedCombatChain_Sorts_Without_A_Dependency_Cycle()
{
// Phase 1.7 added DashTrailDamageSystem ([UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)])
// and KillRewardSystem ([UpdateAfter(HealthApplyDamageSystem)]) to the predicted combat chain. A cycle in
// these [UpdateBefore/After] edges is INVISIBLE to per-system fixtures — it only throws at Play world
// creation. Co-register the chain and sort to reproduce that headlessly (SortSystems only, never Update).
using var world = new World("OrderCyclePredicted");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
void Add<T>() where T : unmanaged, ISystem
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>(); Add<DashTrailDamageSystem>();
Add<AbilityFireSystem>(); Add<ProjectileMoveSystem>(); Add<ProjectileDamageSystem>();
Add<HealthApplyDamageSystem>(); Add<KillRewardSystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A cycle in the Phase 1.7 predicted combat chain throws here instead of only at Play world-creation.");
} }
} }
}
@@ -0,0 +1,62 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Pins <see cref="TimedModifierUtil.Upsert"/> (Phase 1.7 C4): a repeat grant on one SourceId REFRESHES (re-stamps
/// UntilTick) rather than STACKING — exactly one row per id in BOTH the StatModifier and TimedModifier buffers —
/// and the TimedModifier-buffer <see cref="TimedModifierUtil.RemoveBySourceId(DynamicBuffer{TimedModifier}, uint)"/>
/// overload (C5) clears the paired timed row.
/// </summary>
public class TimedModifierUtilTests
{
static (int stat, int timed, uint until) Count(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> timed, uint id)
{
int s = 0; for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == id) s++;
int t = 0; uint u = 0; for (int i = 0; i < timed.Length; i++) if (timed[i].SourceId == id) { t++; u = timed[i].UntilTick; }
return (s, t, u);
}
[Test]
public void Upsert_RefreshesExactlyOneRow_InBothBuffers()
{
using var world = new World("UpsertTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
uint id = Tuning.FrenzySourceId;
for (uint k = 1; k <= 3; k++)
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 100u * k);
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
Assert.AreEqual(1, c.stat, "exactly one StatModifier row (refresh, never stack)");
Assert.AreEqual(1, c.timed, "exactly one TimedModifier row");
Assert.AreEqual(300u, c.until, "UntilTick re-stamped to the latest grant");
}
[Test]
public void RemoveBySourceId_TimedOverload_ClearsPairedRow()
{
using var world = new World("TimedStripTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
uint id = Tuning.FrenzySourceId;
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 500u);
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<StatModifier>(e), id);
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<TimedModifier>(e), id);
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
Assert.AreEqual(0, c.stat, "StatModifier row stripped");
Assert.AreEqual(0, c.timed, "TimedModifier row stripped");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a4b41bc944a5f4340ad5eef53beb2cfc