diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
index db9e7d343..cb1cf3838 100644
--- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
+++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
@@ -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;
diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
index 899a22952..ed036a2f3 100644
--- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
+++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
@@ -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);
diff --git a/Assets/_Project/Scripts/Simulation/Combat/StatMath.cs b/Assets/_Project/Scripts/Simulation/Combat/StatMath.cs
index e9446f342..a94bc2234 100644
--- a/Assets/_Project/Scripts/Simulation/Combat/StatMath.cs
+++ b/Assets/_Project/Scripts/Simulation/Combat/StatMath.cs
@@ -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;
}
}
diff --git a/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs
new file mode 100644
index 000000000..954453980
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs
@@ -0,0 +1,54 @@
+using Unity.Collections;
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// 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 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.
+ ///
+ public static class HarvestMath
+ {
+ ///
+ /// 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).
+ /// may be (unresolvable owner) — the yield then falls
+ /// through to the ledger. is only touched when is true.
+ ///
+ public static bool DepositYield(
+ byte yieldId, int amount, bool toLedger, Entity player,
+ BufferLookup invLookup,
+ DynamicBuffer 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;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs.meta b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs.meta
new file mode 100644
index 000000000..8ece2e334
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f6ff2d3623fe5d3479d5bcfae96c32f9
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
index ae557491e..9f611448d 100644
--- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
@@ -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(out var ledgerEntity);
+ bool haveDb = SystemAPI.TryGetSingleton(out var itemDb);
+ DynamicBuffer ledger = default;
+ if (haveLedger) ledger = SystemAPI.GetBuffer(ledgerEntity);
m_RegionLookup.Update(ref state);
m_InvLookup.Update(ref state);
var meleePlayerByConn = new NativeHashMap(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(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;
diff --git a/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs b/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs
index 7d12966e1..90862afed 100644
--- a/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs
+++ b/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs
@@ -104,7 +104,8 @@ namespace ProjectM.Tests
Assert.AreEqual(0f, slid.y, Eps);
}
-public void SlideVelocity_DegenerateNormal_DeflectsToTangent()
+ [Test]
+ public void SlideVelocity_DegenerateNormal_DeflectsToTangent()
{
// A degenerate (zero / near-vertical) normal has no defined slide plane. The mover must NOT keep driving
// straight into the wall (the enemy-stuck-on-cover-rock bug) -> it deflects to a horizontal tangent of the