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