Files
Project-M/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
T
kronic 62e48a3b0b LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)
The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:59:39 -07:00

414 lines
24 KiB
C#

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>One server-side cleave queued from a player's swing-start this tick, resolved after the player loop so
/// the living-enemy snapshot is gathered ONCE and ONLY when a swing actually fired (never on the client, never on an
/// idle tick). Blittable (Burst-friendly).</summary>
struct PendingCleave
{
public float3 From;
public float2 Face;
public float Damage;
public float Range;
public float KnockSpeed;
public int OwnerId;
public uint Stamp;
public uint KnockUntil;
public bool IsFinisher; // Phase 1.7: this swing is the combo finisher
}
/// <summary>
/// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh
/// <see cref="PlayerInput.Attack"/> press (not locked, not mid-dash) it ADVANCES <see cref="MeleeCombo.Step"/>
/// (chain if re-pressed inside [LockUntilTick, LockUntilTick + grace), else reset to 1) and opens a movement-commit
/// lock; the finisher (Step == ComboLength) hits bigger. The Step/SwingStartTick/LockUntilTick anchor is
/// owner-replicated (<see cref="MeleeCombo"/> [GhostField]s) so a rollback restores the authoritative combo
/// position; every write is an ABSOLUTE function of (restored Step, tick) — re-running a tick re-derives identical
/// state (the DashSystem idempotency idiom; never an in-place prev+1 of a non-restored field — MC-4 review PRED-1).
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it scales the
/// MoveVelocity that system wrote) and BEFORE <see cref="DashSystem"/> (a dash OVERRIDES the swing's movement =
/// dash-cancel) — and so before HealthApplyDamageSystem ([UpdateAfter(DashSystem)]) which drains the cleave's
/// DamageEvent the same tick. Movement-commit re-applies every predicted pass, lower-bounded on SwingStartTick (a
/// re-simulated pre-swing tick must NOT inherit the scale — the DashSystem inDashWindow fix). DAMAGE is SERVER-ONLY
/// (enemies are interpolated ghosts; the client never predicts enemy health) and mirrors ProjectileDamageSystem:
/// queue each swing, then collect ALL living enemies in the per-step cone (<see cref="MeleeConeMath"/>) ONCE, append
/// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with
/// NetworkTick only; feel knobs live in <see cref="TuningConfig"/> (MC-0, fallback to Defaults()).
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[UpdateBefore(typeof(DashSystem))]
[BurstCompile]
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
BufferLookup<StatModifier> m_StatModLookup;
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
const float k_DetonateRadius = 3.5f;
/// <summary>07-20 G2.1: build a cleave payload from LIVE player state at the moment it FIRES (contact tick,
/// flush, or legacy same-tick) -- the cast-turn keeps steering the cone until the blade lands. Static with
/// explicit params (no captures) so Burst lowering stays trivial. Reach uses the RANGE-only finisher mult
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
float2 facingDir, int ownerId, bool hasMods, DynamicBuffer<StatModifier> mods)
{
bool fin = step >= comboLen;
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
float r = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, mods) : baseRange);
return new PendingCleave
{
From = from,
Face = FacingMath.ResolveAim(aim, facingDir),
Damage = fin ? d * finisherMult : d,
Range = fin ? r * finisherRangeMult : r,
KnockSpeed = fin ? knockSpeed * finisherMult : knockSpeed,
OwnerId = ownerId,
Stamp = stamp,
KnockUntil = knockUntil,
IsFinisher = fin,
// Detonate/Pull came from boon flags (deleted 2026-08-07 audit purge).
};
}
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tc) ? tc : TuningConfig.Defaults();
float baseDamage = math.max(0f, t.MeleeDamage);
float baseRange = math.max(0f, t.MeleeRange);
float cosHalf = math.cos(math.max(0f, t.MeleeConeHalfAngleRad));
uint recoverTicks = (uint)math.max(1f, t.MeleeRecoverTicks);
uint graceTicks = (uint)math.max(1f, t.MeleeChainGraceTicks);
float moveScale = math.max(0f, t.MeleeSwingMoveScale);
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
float finisherMult = math.max(1f, t.MeleeFinisherMult);
float finisherRangeMult = math.max(0f, t.MeleeFinisherRangeMult); // 07-20 G2.2: REACH-only finisher scaling
float bufferKnob = math.max(0f, t.MeleeBufferTicks); // 07-20 G7: 0 = buffer off
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G2.1: 0 = immediate (legacy)
byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3);
uint stamp = TickUtil.NonZero(now);
uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks));
bool isServer = state.WorldUnmanaged.IsServer();
// Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only
// when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
m_StatModLookup.Update(ref state);
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>()
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
{
// A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel).
bool dashActive = ds.ValueRO.StartTick != 0u
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick)
&& ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
// Locked: mid swing / recovery (now < LockUntilTick).
bool locked = mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
// --- 07-20 G2.1: fire a DUE scheduled cleave BEFORE anything can overwrite the swing this tick
// (wrap-safe elapsed compare = tick-batch-proof; consumed by zeroing -- the AttackWindup idiom,
// design review wf_000bc247 C11). Server-only; the pending slot never replicates.
bool hasPending = m_PendingLookup.HasComponent(entity);
if (isServer && hasPending)
{
var pend = m_PendingLookup[entity];
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
{
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = default;
}
}
// --- 07-20 G7 buffer: a press in the lock's LAST MeleeBufferTicks is remembered; validity anchors
// to the UNLOCK edge so server tick-batching can't drop an edge press (review wf_000bc247 C1). ---
uint bufTicks = (uint)bufferKnob;
if (bufTicks > 0u && input.ValueRO.Attack.IsSet && locked)
{
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
if (lockEnd.IsValid && lockEnd.TicksSince(serverTick) <= (int)bufTicks)
mc.ValueRW.BufferedAttackTick = TickUtil.NonZero(now);
}
bool buffered = false;
if (!locked && mc.ValueRO.BufferedAttackTick != 0u)
{
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
if (bufTicks > 0u && lockEnd.IsValid && serverTick.TicksSince(lockEnd) <= (int)MeleeTiming.BatchSlackTicks)
buffered = true;
else
mc.ValueRW.BufferedAttackTick = 0u; // stale (missed the slack / buffer tuned off) -- drop
}
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet; a buffered
// press counts as a press -- G7) ---
bool swingStarted = false;
byte swingStep = 0;
if ((input.ValueRO.Attack.IsSet || buffered) && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
{
bool inChainWindow = false;
if (mc.ValueRO.LockUntilTick != 0u)
{
var deadline = new NetworkTick(TickUtil.NonZero(mc.ValueRO.LockUntilTick + graceTicks));
inChainWindow = deadline.IsValid && deadline.IsNewerThan(serverTick); // now < lock+grace (now >= lock since !locked)
}
byte prev = mc.ValueRO.Step;
swingStep = (inChainWindow && prev >= 1 && prev < comboLen) ? (byte)(prev + 1) : (byte)1;
bool isFin = swingStep >= comboLen;
uint stepRecover = isFin ? (uint)math.max(1f, math.round(recoverTicks * finisherMult)) : recoverTicks;
mc.ValueRW.Step = swingStep;
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
mc.ValueRW.BufferedAttackTick = 0u; // consumed (or superseded by a fresh press)
swingStarted = true;
}
// --- MOVEMENT COMMIT (every pass; lower-bounded on SwingStartTick; DashSystem overrides later) ---
bool inSwingWindow = mc.ValueRO.SwingStartTick != 0u
&& !new NetworkTick(mc.ValueRO.SwingStartTick).IsNewerThan(serverTick)
&& mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
if (inSwingWindow && !dashActive)
control.ValueRW.MoveVelocity *= moveScale;
// --- SERVER: schedule the cleave at the CONTACT tick (07-20 G2.1) -- or fire same-tick when the
// knob is 0 (legacy) / the entity has no pending slot (plain test worlds). Damage stays SERVER-only;
// payload is built from LIVE state at the moment it fires (the cast-turn steers until the blade lands).
if (swingStarted && isServer)
{
uint ct = MeleeTiming.ContactTicks(swingStep, contactKnob);
if (ct > 0u && hasPending)
{
// FLUSH a still-armed older cleave first (pathological knob combos can push contact past the
// recover lock -- fire it early rather than lose it; review wf_000bc247 C0/C12).
var pend = m_PendingLookup[entity];
if (pend.ResolveTick != 0u)
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
}
else
{
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
}
}
}
// Resolve queued cleaves SERVER-ONLY and only when one actually fired: gather living enemies ONCE, then
// append a SourceTick-stamped DamageEvent + stamp KnockbackState for each enemy in each swing's cone.
if (isServer && cleaves.IsCreated && cleaves.Length > 0)
{
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, enemyEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAll<EnemyTag>()
.WithEntityAccess())
{
if (health.ValueRO.Current <= 0f)
continue; // skip already-dead enemies (about to despawn)
enemyEntities.Add(enemyEntity);
enemyPositions.Add(xform.ValueRO.Position);
}
// Gather harvest targets (resource nodes + Blight clutter) ONCE so "any attack harvests": a swing
// depletes every node/clutter in its cone, crediting the shared ResourceLedger (the build currency
// pool) just like a base projectile hit. SERVER-ONLY (this whole block) — interpolated node ghosts
// are never rolled back, so the deposit + destroy fire exactly once per swing.
bool haveLedger = SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerEntity);
DynamicBuffer<StorageEntry> ledger = default;
if (haveLedger) ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
var harvEntity = new NativeList<Entity>(Allocator.Temp);
var harvPos = new NativeList<float3>(Allocator.Temp);
var harvRemaining = new NativeList<int>(Allocator.Temp);
var harvYieldId = new NativeList<byte>(Allocator.Temp);
var harvPerHit = new NativeList<float>(Allocator.Temp);
var harvIsClutter = new NativeList<bool>(Allocator.Temp);
var harvVariant = new NativeList<byte>(Allocator.Temp);
foreach (var (hx, node, he) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().WithEntityAccess())
{
if (node.ValueRO.Remaining <= 0) continue; // spent is not a target
harvEntity.Add(he);
harvPos.Add(hx.ValueRO.Position);
harvRemaining.Add(node.ValueRO.Remaining);
harvYieldId.Add(node.ValueRO.ResourceId);
harvPerHit.Add(node.ValueRO.HarvestPerHit);
harvIsClutter.Add(false);
harvVariant.Add(0);
}
foreach (var (hx, clutter, he) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<BlightClutter>>().WithEntityAccess())
{
if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it
harvEntity.Add(he);
harvPos.Add(hx.ValueRO.Position);
harvRemaining.Add(clutter.ValueRO.Remaining);
harvYieldId.Add(clutter.ValueRO.ScrapResourceId);
harvPerHit.Add(clutter.ValueRO.ScrapPerHit);
harvIsClutter.Add(true);
harvVariant.Add(clutter.ValueRO.Variant);
}
var harvDestroyed = new NativeArray<bool>(harvEntity.Length, Allocator.Temp);
m_KnockbackLookup.Update(ref state);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int s = 0; s < cleaves.Length; s++)
{
var c = cleaves[s];
for (int i = 0; i < enemyEntities.Length; i++)
{
if (!MeleeConeMath.InCone(c.From, c.Face, c.Range, cosHalf, enemyPositions[i]))
continue;
var target = enemyEntities[i];
ecb.AppendToBuffer(target, new DamageEvent
{
Amount = c.Damage,
SourceNetworkId = c.OwnerId,
SourceTick = c.Stamp,
});
if (c.KnockSpeed > 0f)
KnockbackUtil.Stamp(ref m_KnockbackLookup, target,
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil);
}
}
// HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write
// Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining.
for (int s = 0; s < cleaves.Length; s++)
{
var hc = cleaves[s];
for (int i = 0; i < harvEntity.Length; i++)
{
if (harvDestroyed[i])
continue;
if (!MeleeConeMath.InCone(hc.From, hc.Face, hc.Range, cosHalf, harvPos[i]))
continue;
int amount = math.max(1, (int)harvPerHit[i]);
// DECOUPLED deposit (cover review): cover has ScrapPerHit=0 — breaks in Remaining hits,
// yields nothing. Nodes/clutter (PerHit >= 1) keep deposit == decrement as before.
// zero means ZERO (cover); any POSITIVE yield still credits >= 1 (the fractional-yield guard).
int deposit = harvPerHit[i] > 0f ? amount : 0;
byte yieldId = harvYieldId[i];
// All yield credits the shared ledger — the PERSONAL inventory sink was deleted with the
// superseded shell (2026-08-07 audit). Only deplete if the yield landed somewhere; never
// consume a node for zero credit (e.g. no ledger singleton present).
if (deposit > 0)
{
bool deposited = HarvestMath.DepositYield(yieldId, deposit, ledger, haveLedger);
if (!deposited)
continue; // never consume a YIELDING target for zero credit
}
int rem = harvRemaining[i] - amount;
harvRemaining[i] = rem;
if (rem <= 0)
{
harvDestroyed[i] = true;
if (harvIsClutter[i] && harvVariant[i] == 3)
{
// EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — mirrors the
// projectile pop site; HazardExplosionSystem detonates (Exploding_Barrels_Build_Spec).
ecb.SetComponent(harvEntity[i], new BlightClutter
{
Remaining = 0,
Variant = harvVariant[i],
ScrapResourceId = harvYieldId[i],
ScrapPerHit = harvPerHit[i],
});
ecb.AddComponent(harvEntity[i], new BarrelFuse
{
ExplodeTick = TickUtil.NonZero(now + Tuning.BarrelFuseTicks),
});
}
else
ecb.DestroyEntity(harvEntity[i]);
}
else if (harvIsClutter[i])
{
ecb.SetComponent(harvEntity[i], new BlightClutter
{
Remaining = rem,
Variant = harvVariant[i],
ScrapResourceId = yieldId,
ScrapPerHit = harvPerHit[i],
});
}
else
{
ecb.SetComponent(harvEntity[i], new ResourceNode
{
ResourceId = yieldId,
Remaining = rem,
HarvestPerHit = harvPerHit[i],
});
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
enemyEntities.Dispose();
enemyPositions.Dispose();
harvEntity.Dispose();
harvPos.Dispose();
harvRemaining.Dispose();
harvYieldId.Dispose();
harvPerHit.Dispose();
harvIsClutter.Dispose();
harvVariant.Dispose();
harvDestroyed.Dispose();
}
if (cleaves.IsCreated)
cleaves.Dispose();
}
}
}