Hygiene B0-B1: correctness & latent traps

- Restore swallowed [Test] on EnemyAIMathTests.SlideVelocity_DegenerateNormal_DeflectsToTangent (dead regression guard for the shipped enemy-stuck-on-cover fix; now runs + passes).
- Extract shared HarvestMath.DepositYield used by ResourceHarvestSystem + MeleeComboSystem; fixes melee mining silently ignoring per-item StackMax (it hard-coded DefaultStackMax) and hoists the melee ledger-buffer fetch out of the per-target loop.
- StatMath.Apply switches on the raw byte (case (byte)ModOp.X) instead of casting to the enum inside the Bursted fold — removes the latent cross-assembly enum-in-Burst ICE trap.
- Add [Min] guards on CycleDirectorAuthoring loss-critical ints (CoreIntegrityMax>=1 so a mis-authored 0 can't bake an instant-loss core; siege sizes >=0).

456/456 EditMode tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:53:32 -07:00
parent 7b64c1caa3
commit b1041003f0
7 changed files with 80 additions and 47 deletions
@@ -23,10 +23,10 @@ namespace ProjectM.Authoring
public uint PostExpeditionDelayTicks = 300;
[Tooltip("Siege size floor (Husk count) for a post-expedition retaliation.")]
public int SiegeSizeBase = 5;
[Min(0)] public int SiegeSizeBase = 5;
[Tooltip("Extra Husks per unit of resources hauled back this run (0 = flat SiegeSizeBase).")]
public int SiegeSizePerResource = 0;
[Min(0)] public int SiegeSizePerResource = 0;
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
public uint SiegeTimeoutTicks = 3600;
@@ -38,11 +38,11 @@ namespace ProjectM.Authoring
public uint ScheduleIntervalTicks = 2700;
[Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")]
public int ScheduleSizePerWave = 1;
[Min(0)] public int ScheduleSizePerWave = 1;
[Header("Endgame — Engine Core (END-1)")]
[Tooltip("Baked integrity ceiling of the losable Engine Core. Current is born full (or the persisted wounded value on Continue).")]
public int CoreIntegrityMax = 100;
[Min(1)] public int CoreIntegrityMax = 100;
@@ -149,28 +149,12 @@ namespace ProjectM.Server
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
int remainder = amount;
// Base-region nodes credit the SHARED ledger DIRECTLY (the build currency pool); expedition / un-tagged
// nodes keep the personal-inventory reroute (spill-to-ledger). Untagged -> not Base -> inventory path.
if (!tgtToLedger[bestIdx]
&& m_GhostOwnerLookup.HasComponent(projEntity)
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player)
&& m_InvLookup.HasBuffer(player))
{
int stackMax = Tuning.DefaultStackMax;
if (haveDb && itemDb.Value.IsCreated)
{
ref var itemBlob = ref itemDb.Value.Value;
if (itemBlob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
stackMax = def.StackMax;
}
var inv = m_InvLookup[player];
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
}
// Unresolvable owner or a full bag: the remainder credits the shared ledger (no-loss valve).
if (remainder > 0)
StorageMath.Deposit(ledger, yieldId, remainder);
Entity harvester = Entity.Null;
if (m_GhostOwnerLookup.HasComponent(projEntity)
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var ownedPlayer))
harvester = ownedPlayer;
HarvestMath.DepositYield(yieldId, amount, tgtToLedger[bestIdx], harvester,
m_InvLookup, ledger, true, haveDb, itemDb);
int rem = tgtRemaining[bestIdx] - amount;
tgtRemaining[bestIdx] = rem;
ecb.DestroyEntity(projEntity);
@@ -28,11 +28,11 @@ namespace ProjectM.Simulation
var m = mods[i];
if (m.Target != t)
continue;
switch ((ModOp)m.Op)
switch (m.Op)
{
case ModOp.Flat: flat += m.Value; break;
case ModOp.PercentAdd: percentAdd += m.Value; break;
case ModOp.PercentMult: percentMult *= 1f + m.Value; break;
case (byte)ModOp.Flat: flat += m.Value; break;
case (byte)ModOp.PercentAdd: percentAdd += m.Value; break;
case (byte)ModOp.PercentMult: percentMult *= 1f + m.Value; break;
}
}
@@ -0,0 +1,54 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Shared harvest-yield deposit routing used by BOTH the projectile-sweep harvest (ResourceHarvestSystem) and
/// the melee-cone harvest (MeleeComboSystem), so the two can't drift. (They previously did: the melee path
/// hard-coded <see cref="Tuning.DefaultStackMax"/> and silently ignored per-item stack caps.) Base-region yield
/// credits the shared ledger DIRECTLY (the build-currency pool); expedition / un-tagged yield routes to the
/// harvesting player's PERSONAL inventory — per-item StackMax from the item catalog, fallback DefaultStackMax —
/// and spills any overflow to the ledger (the no-loss valve). Pure + Burst-friendly.
/// </summary>
public static class HarvestMath
{
/// <summary>
/// Routes one harvested yield to its sink. Returns true if the yield landed somewhere (inventory or ledger);
/// callers use this to avoid consuming a target for zero credit (e.g. no ledger singleton present).
/// <paramref name="player"/> may be <see cref="Entity.Null"/> (unresolvable owner) — the yield then falls
/// through to the ledger. <paramref name="ledger"/> is only touched when <paramref name="haveLedger"/> is true.
/// </summary>
public static bool DepositYield(
byte yieldId, int amount, bool toLedger, Entity player,
BufferLookup<InventorySlot> invLookup,
DynamicBuffer<StorageEntry> ledger, bool haveLedger,
bool haveDb, in ItemDatabase itemDb)
{
int remainder = amount;
bool deposited = false;
if (!toLedger && player != Entity.Null && invLookup.HasBuffer(player))
{
int stackMax = Tuning.DefaultStackMax;
if (haveDb && itemDb.Value.IsCreated)
{
ref var blob = ref itemDb.Value.Value;
if (blob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
stackMax = def.StackMax;
}
var inv = invLookup[player];
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
deposited = true;
}
if (remainder > 0 && haveLedger)
{
StorageMath.Deposit(ledger, yieldId, remainder);
deposited = true;
}
return deposited;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6ff2d3623fe5d3479d5bcfae96c32f9
@@ -187,6 +187,9 @@ namespace ProjectM.Simulation
// 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);
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
DynamicBuffer<StorageEntry> ledger = default;
if (haveLedger) ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
m_RegionLookup.Update(ref state);
m_InvLookup.Update(ref state);
var meleePlayerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
@@ -272,22 +275,11 @@ namespace ProjectM.Simulation
// expedition / un-tagged target goes to the swinging player's PERSONAL inventory (spill to
// ledger), mirroring ResourceHarvestSystem. Only deplete if the yield landed somewhere —
// never consume a node for zero credit (e.g. no ledger singleton present).
int remainder = amount;
bool deposited = false;
if (!harvToLedger[i]
&& meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer)
&& m_InvLookup.HasBuffer(meleePlayer))
{
var inv = m_InvLookup[meleePlayer];
remainder = InventoryMath.Deposit(inv, yieldId, amount, Tuning.DefaultStackMax, Tuning.InventoryMaxSlots);
deposited = true;
}
if (remainder > 0 && haveLedger)
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
StorageMath.Deposit(ledger, yieldId, remainder);
deposited = true;
}
Entity meleeHarvester = Entity.Null;
if (meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer))
meleeHarvester = meleePlayer;
bool deposited = HarvestMath.DepositYield(yieldId, amount, harvToLedger[i], meleeHarvester,
m_InvLookup, ledger, haveLedger, haveDb, itemDb);
if (!deposited)
continue;