diff --git a/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs b/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs deleted file mode 100644 index 5962a3b66..000000000 --- a/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs +++ /dev/null @@ -1,39 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Baker helpers shared by the build-structure / automation-machine bakers. These emit the SAME component data - /// the bakers wrote inline before (no serialized authoring field or baked component change) — they only - /// deduplicate the repeated stamp and the damageable triad - /// ( + buffer + ). Extension methods on - /// the concrete (not IBaker, whose AddComponent/AddBuffer are obsolete) so - /// the non-obsolete public Baker API resolves. - /// - public static class BakerStructureExt - { - /// Stamp PlacedStructure{Type=type} with the standard baked defaults (Cell/NextTick/LastProcessedTick set at placement). - public static void AddPlacedStructure(this Baker baker, Entity e, byte type) - where TAuthoring : Component - { - baker.AddComponent(e, new PlacedStructure - { - Type = type, - Cell = default, - NextTick = 0u, - LastProcessedTick = 0u, - }); - } - - /// Make an entity damageable/destructible: Health{Current=Max=maxHp} + the required DamageEvent buffer + Destructible tag. - public static void AddDamageable(this Baker baker, Entity e, float maxHp) - where TAuthoring : Component - { - baker.AddComponent(e, new Health { Current = maxHp, Max = maxHp }); - baker.AddBuffer(e); - baker.AddComponent(e); - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs.meta b/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs.meta deleted file mode 100644 index dde9dad55..000000000 --- a/Assets/_Project/Scripts/Authoring/BakerStructureExt.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a54e2710fbe8cca4a9e84e9bbca4f7b9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs deleted file mode 100644 index 365c344d2..000000000 --- a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs +++ /dev/null @@ -1,35 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Generic authoring for a non-functional build-structure ghost prefab (Wall / Pylon) — duplicate - /// Turret.prefab so the ownerless interpolated GhostAuthoringComponent on PlacedStructure.Type comes free, - /// then swap TurretAuthoring for this. Bakes ONLY {Type=} - /// (no stats, so TurretFireSystem ignores it). BuildPlaceSystem overrides Cell + - /// LastProcessedTick and adds RegionTag{Base} at placement. is a byte (StructureType.*) to - /// dodge the cross-assembly enum-in-Burst hazard and the MCP enum-drop gotcha. - /// - public class StructureAuthoring : MonoBehaviour - { - [Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")] - public byte Kind = StructureType.Wall; - - [Min(1f)] public float MaxHp = 150f; - - private class StructureBaker : Baker - { - public override void Bake(StructureAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - this.AddPlacedStructure(entity, authoring.Kind); - // EB-1: Wall/Pylon are damageable + destructible AI targets (a wall soaks Husk strikes that would - // otherwise hit a turret). DamageEvent buffer MUST exist or an AI strike crashes at ECB playback. - // No HitRadius -> ProjectileDamageSystem ignores them (no friendly projectile fire). - this.AddDamageable(entity, authoring.MaxHp); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs.meta deleted file mode 100644 index 090a937b9..000000000 --- a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3f03349205fb1fe43bf6aaff14fce0b7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs deleted file mode 100644 index e5ebbff9c..000000000 --- a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs +++ /dev/null @@ -1,60 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; -using UnityEngine.Serialization; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the baked singleton (the build cost/prefab table). Flat - /// prefab + cost fields per buildable type (the type + cost-resource are byte consts in the baker — - /// enum-via-MCP is unreliable, and bytes dodge the cross-assembly enum-in-Burst hazard); the runtime - /// buffer is the data-driven shape. Place once in the gameplay subscene. - /// - public class StructureCatalogAuthoring : MonoBehaviour - { - [Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")] - public GameObject WallPrefab; - - [Tooltip("Biomass cost to build a wall.")] - [Min(0)] [FormerlySerializedAs("WallCostOre")] public int WallCostBiomass = 4; - - [Tooltip("Pylon cosmetic-beacon ghost prefab (StructureAuthoring{Pylon} + GhostAuthoring).")] - public GameObject PylonPrefab; - - [Tooltip("Ore cost to build a pylon.")] - [Min(0)] public int PylonCostOre = 2; - - private class StructureCatalogBaker : Baker - { - public override void Bake(StructureCatalogAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - AddComponent(entity); - var buf = AddBuffer(entity); - - if (authoring.WallPrefab != null) - { - buf.Add(new StructureCatalogEntry - { - Type = StructureType.Wall, - Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic), - CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink) - CostAmount = authoring.WallCostBiomass, - }); - } - - if (authoring.PylonPrefab != null) - { - buf.Add(new StructureCatalogEntry - { - Type = StructureType.Pylon, - Prefab = GetEntity(authoring.PylonPrefab, TransformUsageFlags.Dynamic), - CostResourceId = ResourceId.Ore, - CostAmount = authoring.PylonCostOre, - }); - } - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs.meta deleted file mode 100644 index 400430a9a..000000000 --- a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 40093ed42072f5a4889f5f62f510aa27 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs deleted file mode 100644 index 9b1ae3dc6..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the boon-catalog config singleton (place ONE in the gameplay subscene). Designers can author - /// rows in the inspector; an EMPTY list bakes the code-default v1 table () - /// verbatim — so the subscene object needs zero property assignment through the tooling (the - /// component_properties enum/array-drop hazard). Ids are APPEND-ONLY (they ride the replicated BoonOffer bytes - /// and, later, per-run analytics). - /// - public class BoonCatalogAuthoring : MonoBehaviour - { - [Serializable] - public struct BoonRow - { - public byte Id; - public StatTarget Target; - public ModOp Op; - public float Value; - [Tooltip("Rarity draw weight: common 100 / rare 30 / epic 10. 0 removes the boon from the pool.")] - public byte Weight; - [Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")] - public byte ClassMask; - public string Name; - public string Desc; - } - - [Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")] - public List Rows = new List(); - - private class BoonCatalogBaker : Baker - { - public override void Bake(BoonCatalogAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - - BlobAssetReference blob; - if (authoring.Rows == null || authoring.Rows.Count == 0) - { - blob = BoonCatalogData.BuildDefault(); - } - else - { - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count); - for (int i = 0; i < authoring.Rows.Count; i++) - { - var r = authoring.Rows[i]; - defs[i] = new BoonDefBlob - { - Id = r.Id, - Target = (byte)r.Target, - Op = (byte)r.Op, - Value = r.Value, - Weight = r.Weight, - ClassMask = r.ClassMask, - Name = new FixedString64Bytes(r.Name ?? string.Empty), - Desc = new FixedString128Bytes(r.Desc ?? string.Empty), - }; - } - blob = builder.CreateBlobAssetReference(Allocator.Persistent); - builder.Dispose(); - } - - AddBlobAsset(ref blob, out _); - AddComponent(entity, new BoonCatalog { Value = blob }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta deleted file mode 100644 index 53d5ebbd4..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 075a570afb8ef9541b6307280b53f4e7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs deleted file mode 100644 index 6c0b588a6..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// MC-1 — marks a Husk prefab as a CHARGER variant. Compose this WITH on the - /// prefab root (both bakers share the primary entity): EnemyAuthoring bakes the common Husk components and - /// Charger-tuned stats; this bakes the server-only (zeroed = not lunging). - /// Component-PRESENCE is the discriminator EnemyAISystem branches on — no enum/brain byte (the Burst - /// cross-assembly-enum hazard) — routing the Charger to the commit→lunge→whiff-stagger pass while the Grunt - /// pass excludes it via .WithNone<LungeState>(). NOT a [GhostField]: the lunged position - /// replicates via stock LocalTransform like every Husk. - /// - public class ChargerAuthoring : MonoBehaviour - { - private class ChargerBaker : Baker - { - public override void Bake(ChargerAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity); - // Slice 1 (Feature D): the replicated mid-lunge cue, baked DISABLED (a Charger spawns not-lunging). - // EnemyAISystem derives the bit each tick from LungeState.UntilTick (visiting disabled entities via - // .WithPresent()). Adding this [GhostEnabledBit] changes the Charger ghost hash -> RE-BAKE. - AddComponent(entity); - SetComponentEnabled(entity, false); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs.meta deleted file mode 100644 index 505df6336..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9565191e0ea7fc94db934ae91a43a4cf \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index 8e611bfc5..7043766ec 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -60,16 +60,11 @@ namespace ProjectM.Authoring // denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only). // Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the // SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*). - byte kind = ZoneEnemyMath.KindGrunt; + // 2026-08-07 audit purge: the Charger/Spitter/Swarmer variant authoring is gone — it was attached + // to ZERO prefabs, so every enemy already baked Kind=Grunt and the variant AI passes matched + // nothing. One kind, one windup, until the LANTERN bestiary reintroduces variety via CreatureKit. + const byte kind = ZoneEnemyMath.KindGrunt; byte windup = (byte)Tuning.AttackWindupTicks; - var spitter = GetComponent(); - // Bake-time guard (DR-041 sole-Position-writer invariant): a prefab must carry at most ONE of - // {ChargerAuthoring(LungeState), SpitterAuthoring(SpitterState)} — both would match ZERO AI passes. - if (GetComponent() != null && spitter != null) - Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring); - if (GetComponent() != null) { kind = ZoneEnemyMath.KindCharger; windup = (byte)Tuning.ChargerWindupTicks; } - else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); } - else if (GetComponent() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = (byte)Tuning.AttackWindupTicks; /* B4: match the server windup (grunt-path swarmers use GruntWindupTicks); baked 6 was a snap-ramp lie */ } AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind }); } } diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs deleted file mode 100644 index 60cdf5c95..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// MC-2 — authoring for the hostile Spitter projectile prefab (an ownerless INTERPOLATED ghost, duplicated from an - /// existing interpolated ghost so the GhostAuthoringComponent comes free). Bakes with - /// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn - /// and ADDS the RegionTag (so this prefab MUST NOT bake RegionTag — AddComponent would throw on a baked one). - /// NO Health (so it is invisible to every player hit-test) and NO [GhostField] beyond the stock LocalTransform. - /// - public class EnemyProjectileAuthoring : MonoBehaviour - { - [Min(0f), Tooltip("Default muzzle speed (the firing Spitter overrides this per-variant).")] - public float Speed = 11f; - - [Min(0f), Tooltip("Default damage (the firing Spitter overrides this from its AttackDamage).")] - public float Damage = 8f; - - [Min(0f), Tooltip("Max travel distance before the spit expires (world units).")] - public float Range = 16f; - - private class EnemyProjectileBaker : Baker - { - public override void Bake(EnemyProjectileAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity, new EnemyProjectile - { - Speed = authoring.Speed, - Damage = authoring.Damage, - Range = authoring.Range, - Direction = new float2(0f, 1f), - DistanceTravelled = 0f, - LastStep = 0f, - Region = 0, - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta deleted file mode 100644 index af179adb4..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ff79c8fbcacb8c34faad37d59836b5ac \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs deleted file mode 100644 index 3dd46d24f..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs +++ /dev/null @@ -1,49 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH - /// on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's - /// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only - /// (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem - /// branches on (no enum); the Grunt + Charger passes exclude it via .WithNone<SpitterState>(). The - /// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton. - /// - public class SpitterAuthoring : MonoBehaviour - { - [Min(0f), Tooltip("Distance the Spitter tries to hold from its target (band centre).")] - public float PreferredRange = 9f; - - [Min(0f), Tooltip("Half-width dead-zone around PreferredRange where it holds and fires.")] - public float RangeTolerance = 1.5f; - - [Min(0f), Tooltip("Muzzle speed of the spit (world units/second). Slow enough to be dodgeable at range.")] - public float ProjectileSpeed = 11f; - - [Min(0f), Tooltip("If the target closes within this AND the Spitter can't retreat, it fires point-blank.")] - public float CorneredRange = 3f; - - [Min(1), Tooltip("Telegraph wind-up before the spit fires (ticks). Keep >= ~24 (> interp delay) to stay dodgeable.")] - public int WindupTicks = 26; - - private class SpitterBaker : Baker - { - public override void Bake(SpitterAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity, new SpitterState - { - PreferredRange = authoring.PreferredRange, - RangeTolerance = authoring.RangeTolerance, - ProjectileSpeed = authoring.ProjectileSpeed, - CorneredRange = authoring.CorneredRange, - WindupTicks = authoring.WindupTicks, - NextShotTick = 0, - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta deleted file mode 100644 index deb89a591..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 55fe00810b31aa54abd577b6a07192e2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs deleted file mode 100644 index bdfd51784..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs +++ /dev/null @@ -1,36 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// MC-2 — authoring for the subscene singleton. Place ONE on a GameObject in - /// the gameplay subscene; the server EnemyAISystem Spitter pass reads it via GetSingleton to know which spit - /// ghost to instantiate and the concurrent soft-cap. The referenced prefab is the EnemyProjectile ghost - /// (EnemyProjectileAuthoring); MaxLiveProjectiles bounds the relevancy loop — a Spitter at/over the cap soft-fails - /// its shot (no cooldown burn). The carrying entity has no transform; only the referenced prefab needs one. - /// - public class SpitterProjectilePrefabAuthoring : MonoBehaviour - { - [Tooltip("The EnemyProjectile ghost prefab that Spitters fire (must carry EnemyProjectileAuthoring + an interpolated GhostAuthoringComponent).")] - public GameObject ProjectilePrefab; - - [Min(1), Tooltip("Max concurrent live spit projectiles across all Spitters (soft-cap; over it a Spitter soft-fails its shot).")] - public int MaxLiveProjectiles = 24; - - private class SpitterProjectilePrefabBaker : Baker - { - public override void Bake(SpitterProjectilePrefabAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - AddComponent(entity, new SpitterProjectilePrefab - { - Prefab = authoring.ProjectilePrefab != null - ? GetEntity(authoring.ProjectilePrefab, TransformUsageFlags.Dynamic) : Entity.Null, - MaxLiveProjectiles = authoring.MaxLiveProjectiles, - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs.meta deleted file mode 100644 index 4986c335c..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SpitterProjectilePrefabAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4ce5223c5fd56694c81991e1bb1232de \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs deleted file mode 100644 index 28a0499bd..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH - /// on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the - /// EnemyAuthoring fields). This bakes only the marker: a Swarmer has NO AI branch (it - /// falls through the Grunt seek+strike pass); the tag drives the director's CLUSTER spawn (a pack per slot) + a - /// client tint. Keeps EnemyTag + RegionTag like every Husk. - /// - public class SwarmerAuthoring : MonoBehaviour - { - private class SwarmerBaker : Baker - { - public override void Bake(SwarmerAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta deleted file mode 100644 index 6725a0f9b..000000000 --- a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b6a84b442d0535642abc303c01546a15 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs b/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs deleted file mode 100644 index ab7c927df..000000000 --- a/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the shared storage-container ghost prefab: an ownerless INTERPOLATED ghost whose - /// replicated buffer is the shared inventory any player deposits into / - /// withdraws from (server-authoritative, applied by StorageOpReceiveSystem). Add a - /// GhostAuthoringComponent (Interpolated) to the prefab so clients see its contents replicate. - /// GetEntity(TransformUsageFlags.Dynamic) gives it a runtime world transform, set at spawn to - /// the base cell center. - /// - public class SharedStorageContainerAuthoring : MonoBehaviour - { - [Min(0f)] - [Tooltip("Interaction radius (world units) for the deposit/withdraw test; reserved for proximity gating.")] - public float InteractRadius = 2f; - - private class SharedStorageContainerBaker : Baker - { - public override void Bake(SharedStorageContainerAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity); - AddComponent(entity, new HitRadius { Value = authoring.InteractRadius }); - AddBuffer(entity); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs.meta deleted file mode 100644 index f4df69d92..000000000 --- a/Assets/_Project/Scripts/Authoring/HomeBase/SharedStorageContainerAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8cc6285a9b8958a47a61a07550b7f792 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs b/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs deleted file mode 100644 index d1b06b32e..000000000 --- a/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the baked singleton (mirrors UpgradePickupSpawnerAuthoring). - /// Place once in the gameplay subscene; the server-only SharedStorageSpawnSystem reads it, instantiates - /// the storage-container ghost at the base-grid cell center (BaseGridMath.CellToWorld), then destroys - /// the singleton so it fires exactly once. The entity carries no transform; only the prefab needs one. - /// - public class StorageSpawnerAuthoring : MonoBehaviour - { - [Tooltip("Storage-container ghost prefab to instantiate. Must carry SharedStorageContainerAuthoring + a GhostAuthoringComponent.")] - public GameObject ContainerPrefab; - - [Tooltip("Build-grid cell at which to place the container (cell center, on the base plane).")] - public Vector2Int Cell = new Vector2Int(16, 22); - - private class StorageSpawnerBaker : Baker - { - public override void Bake(StorageSpawnerAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - - AddComponent(entity, new StorageSpawner - { - Prefab = authoring.ContainerPrefab != null - ? GetEntity(authoring.ContainerPrefab, TransformUsageFlags.Dynamic) - : Entity.Null, - Cell = new int2(authoring.Cell.x, authoring.Cell.y), - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs.meta deleted file mode 100644 index a66f9937e..000000000 --- a/Assets/_Project/Scripts/Authoring/HomeBase/StorageSpawnerAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 968b8c85b6f69ae438e56cb1f19a2450 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs deleted file mode 100644 index 42234f80d..000000000 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Bakes the designer-authored item definitions into a single ItemDatabase blob singleton (immutable, - /// shared, Burst-fast), mirroring AbilityDatabaseAuthoring. Place ONE in the gameplay subscene; it streams - /// identically into the client and server worlds (config, not replicated). DependsOn each definition so a - /// value change re-bakes the blob. Runtime lookup is ID-keyed (ItemDatabaseBlob.TryGetItem), so the list - /// order here does not matter and inserting an item never renumbers existing ids. - /// - public class ItemDatabaseAuthoring : MonoBehaviour - { - [Tooltip("All item definitions in the game (resources + tools/gear). Looked up at runtime by ItemId.")] - public List Items = new List(); - - private class DatabaseBaker : Baker - { - public override void Bake(ItemDatabaseAuthoring authoring) - { - var entity = GetEntity(TransformUsageFlags.None); - - int count = authoring.Items != null ? authoring.Items.Count : 0; - - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var arr = builder.Allocate(ref root.Items, count); - for (int i = 0; i < count; i++) - { - var def = authoring.Items[i]; - if (def == null) { arr[i] = default; continue; } - DependsOn(def); - arr[i] = new ItemDefBlob - { - ItemId = (ushort)def.ItemId, - Category = def.Category, - Tier = def.Tier, - StackMax = def.StackMax, - EquipSlot = def.EquipSlot, - Mod0 = ModAt(def, 0), - Mod1 = ModAt(def, 1), - Mod2 = ModAt(def, 2), - Mod3 = ModAt(def, 3), - Name = def.DisplayName, - }; - } - - var blob = builder.CreateBlobAssetReference(Allocator.Persistent); - builder.Dispose(); - AddBlobAsset(ref blob, out _); - AddComponent(entity, new ItemDatabase { Value = blob }); - - static ItemModSpec ModAt(ItemDefinition def, int i) - { - if (def.Mods != null && i < def.Mods.Count) - { - var m = def.Mods[i]; - return new ItemModSpec { Target = m.Target, Op = m.Op, Value = m.Value }; - } - return new ItemModSpec { Target = 255 }; - } - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta deleted file mode 100644 index 7dcd14c91..000000000 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5ee44dc3bc9f3164592195d4068be8d1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs deleted file mode 100644 index 358e3a794..000000000 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Designer-facing definition of one item (resource, tool, weapon, gear, consumable). Numeric/byte fields - /// are baked into the ItemDatabase blob (immutable, Burst-fast runtime config). Category and Tier are - /// BYTES, not enums, to dodge BOTH the MCP enum-drop hazard (manage_* silently drop enum fields when set - /// via tooling) AND the cross-assembly enum-in-Burst hazard. UI icon/description are deferred to a later - /// managed lookup keyed by id, exactly like AbilityDefinition. - /// - [CreateAssetMenu(menuName = "Project M/Item Definition", fileName = "Item_")] - public class ItemDefinition : ScriptableObject - { - [Tooltip("Stable item id (ushort range). Keep 1=Aether, 2=Ore, 3=Biomass; reserve >3 for new items; 0 = none.")] - public int ItemId = 4; - - public string DisplayName = "Item"; - - [Tooltip("ItemCategory byte: 0=Resource, 1=Tool, 2=Weapon, 3=Gear, 4=Consumable.")] - public byte Category = ItemCategory.Resource; - - [Tooltip("Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder.")] - public byte Tier = 0; - - [Min(1)] - [Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")] - public int StackMax = 999; - - [Header("Equipment (Phase 1)")] - [Tooltip("EquipSlotId byte: 0=Weapon, 1=Armor, 2=Trinket, 3=Tool, 255=not equippable.")] - public byte EquipSlot = 255; - - [Tooltip("Stat modifiers granted while equipped (first 4 used).")] - public List Mods = new List(); - } - - /// Designer-facing stat-mod grant on an equippable item; the baker writes the first 4 into ItemDefBlob's inline mod slots. - [System.Serializable] - public struct ItemModAuthoring - { - [Tooltip("StatTarget byte: 0=Damage,1=CooldownTicks,2=Range,3=ProjectileSpeed,4=AutoTargetRange,5=AutoTargetCone,6=MoveSpeed,7=TurnRate,8=MaxHealth.")] - public byte Target; - [Tooltip("ModOp byte: 0=Flat, 1=PercentAdd, 2=PercentMult.")] - public byte Op; - public float Value; - } -} diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta deleted file mode 100644 index 0a7a91fba..000000000 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 84295e2f852afac4fa4b7384857281d9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs deleted file mode 100644 index 5b6ba8c8a..000000000 --- a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the PERMANENT meta-upgrade catalog singleton (place ONE in the gameplay subscene — the - /// BoonCatalogAuthoring pattern). An EMPTY row list bakes the code-default v1 table - /// () verbatim, so the subscene object needs zero property assignment - /// through the tooling. Ids are APPEND-ONLY (persisted in SaveData v6; a removed id's saved rows are preserved - /// and skipped, never crashed on). - /// - public class MetaCatalogAuthoring : MonoBehaviour - { - [Serializable] - public struct MetaRow - { - public byte Id; - [Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")] - public byte ClassMask; - public StatTarget Target; - public ModOp Op; - public byte MaxTier; - public float ValuePerTier; - public int BaseCost; - public int CostGrowth; - [Tooltip("0xFF (255) = no prerequisite.")] - public byte PrereqId; - public byte PrereqTier; - public string Name; - public string Desc; - } - - [Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")] - public List Rows = new List(); - - private class MetaCatalogBaker : Baker - { - public override void Bake(MetaCatalogAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - - BlobAssetReference blob; - if (authoring.Rows == null || authoring.Rows.Count == 0) - { - blob = MetaCatalogData.BuildDefault(); - } - else - { - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count); - for (int i = 0; i < authoring.Rows.Count; i++) - { - var r = authoring.Rows[i]; - defs[i] = new MetaUpgradeDefBlob - { - Id = r.Id, - ClassMask = r.ClassMask, - Target = (byte)r.Target, - Op = (byte)r.Op, - MaxTier = r.MaxTier, - ValuePerTier = r.ValuePerTier, - BaseCost = r.BaseCost, - CostGrowth = r.CostGrowth, - PrereqId = r.PrereqId, - PrereqTier = r.PrereqTier, - Name = new FixedString64Bytes(r.Name ?? string.Empty), - Desc = new FixedString128Bytes(r.Desc ?? string.Empty), - }; - } - blob = builder.CreateBlobAssetReference(Allocator.Persistent); - builder.Dispose(); - } - - AddBlobAsset(ref blob, out _); - AddComponent(entity, new MetaUpgradeCatalog { Value = blob }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta deleted file mode 100644 index 38bc113ff..000000000 --- a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4f007f7870c0afd4e93ea5b86fd21c8b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index e5dbe302e..582b81027 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -60,12 +60,9 @@ namespace ProjectM.Authoring // Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative). AddBuffer(entity); - // Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here). - AddBuffer(entity); - // Equipment loadout: one replicated row per slot in FIXED order (buffer index = EquipSlotId), empty. - var equip = AddBuffer(entity); - for (int s = 0; s < EquipSlotId.Count; s++) - equip.Add(new EquipmentSlot { ItemId = 0 }); + // 2026-08-07 audit purge: the replicated personal InventorySlot bag and the EquipmentSlot loadout + // went with the shell (CLAUDE.md already recorded inventory/equipment as PAUSED). Harvest now + // credits the shared ledger directly. // Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated). AddBuffer(entity); @@ -94,14 +91,9 @@ namespace ProjectM.Authoring AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks }); AddComponent(entity, new RespawnInvuln { UntilTick = 0 }); - // Expedition redesign (the ONE player-ghost re-bake, front-loaded): the send-to-all ready-check - // flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up). - AddComponent(entity); - AddComponent(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(entity); - AddComponent(entity); + // 2026-08-07 audit purge: PlayerReady (ready-check), BoonOffer/BoonEffects (Phase-1.7 boons) and + // DashTrailState (Blade Dash) were baked onto every player ghost for the superseded base/expedition + // loop. All four are gone; the ghost archetype shrinks accordingly. // LANTERN Phase 1 (Step 1): 4-socket kit data model — THE ability model (the legacy single // AbilityRef/AbilityCooldown path is deleted). AbilitySocket = cold per-socket loadout // (EquipmentSlot-modelled, 4 empty rows; GoInGameServerSystem seeds the frame loadout at spawn); diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index afce26453..65017e32e 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -23,15 +23,10 @@ namespace ProjectM.Authoring AddComponent(entity); AddBuffer(entity); - // DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout). - // Born Idle; RoomEnemyDirectorSystem is the sole writer. - AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 }); - - // Expedition redesign: the replicated run-lifecycle FSM (RunInfo) + the per-class permanent-meta - // tier buffer (MetaTierState). Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are - // the sole writers. - AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging }); - AddBuffer(entity); + // 2026-08-07 audit purge: ExpeditionObjective (the 'enemies remaining / cleared' readout), + // RunInfo (the run-lifecycle FSM) and MetaTierState (per-frame permanent upgrades) were all baked + // here for the superseded base/expedition loop. Their sole writers are deleted; the director now + // carries only the global resource ledger. } } } diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs deleted file mode 100644 index 951b3617d..000000000 --- a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace ProjectM.Client -{ - /// - /// Client-local build-mode state shared between the HUD build palette (sets the selected buildable), the - /// build placement input (ground ghost preview + click-to-place + conveyor rotation), and the input gather - /// (suppresses Fire while a build is selected, so a left-click places instead of firing). Pure UI state — - /// never replicated, never touches the sim. Reset on play-enter (statics survive fast-enter domain reloads). - /// - public static class BuildPaletteState - { - /// Selected structure type (StructureType.*); 0 = none / no slot selected. - public static byte Selected; - - /// Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R. - public static byte Direction; - - /// True while the build PALETTE panel is open (toggled by Tab / gamepad Y). Slice 1 HUD declutter: - /// the palette is hidden by default; this gates its visibility, orthogonal to . - public static bool PaletteOpen; - - /// True while a buildable SLOT is selected (placement is armed). The palette must also be open. - public static bool Active => Selected != 0; - - /// Toggle the palette panel open/closed; closing also cancels any active slot selection. - public static void TogglePalette() - { - PaletteOpen = !PaletteOpen; - if (!PaletteOpen) { Selected = 0; Direction = 0; } - } - - /// Select a type (or 0 to deselect), resetting the pending conveyor facing; auto-opens the palette - /// so a slot click never leaves the panel hidden. - public static void Select(byte type) { Selected = type; Direction = 0; if (type != 0) PaletteOpen = true; } - - /// Cancel the current selection and close the palette. - public static void Clear() { Selected = 0; Direction = 0; PaletteOpen = false; } - - [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() { Selected = 0; Direction = 0; PaletteOpen = false; } - } -} diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta deleted file mode 100644 index 99674e144..000000000 --- a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b0f12fac7a937bf418aaf47eba57cc74 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs deleted file mode 100644 index f787e88a4..000000000 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ /dev/null @@ -1,267 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-only build input + RPC sender. Two ways to build: - /// (1) the HUD build PALETTE (primary): a selected buildable () drives a ground - /// GHOST preview at the cursor cell — green when valid (in-plot, unoccupied, affordable; the same legality the - /// server re-validates), red otherwise — and a LEFT-CLICK places it; right-click / Esc cancels; [ / ] or R - /// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads - /// ), so the place-click never also fires. Build mode is suspended while - /// the pause overlay is open, and the frame a palette button changes the selection never also places. - /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/F place at the local player's cell. - /// Editor-only statics (PlaceStructure / PlaceTurret / ...) drive the same RPC path from execute_code for - /// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the - /// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class BuildSendSystem : SystemBase - { - - // Dev hotkey fallback: key -> buildable. - static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys = - { - (UnityEngine.InputSystem.Key.V, StructureType.Wall), - // LANTERN purge: the automation buildables (Fabricator/Harvester/Conveyor) are deleted; Pylon stays - // hidden from the hotkey fallback to match the build palette. PlaceStructure execute_code statics remain. - }; - - UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) - GameObject _ghost; // translucent ground preview cube - Material _ghostMat; - MeshFilter _ghostMf; // ghost mesh swaps to the selected structure's preview mesh (HudTheme) - MeshRenderer _ghostMr; - Mesh _cubeMesh; // procedural fallback (the original preview cube) - byte _ghostType = 255; // last-applied preview type (255 = unset) - byte _lastSelected; // skip placing on the frame a palette click changes the selection - - // (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons + - // the base meta-shop replaced the Aether damage upgrade.) - -#if UNITY_EDITOR - struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } - static readonly System.Collections.Generic.Queue s_PendingBuild = - new System.Collections.Generic.Queue(); - - /// EDITOR / execute_code hook: queue a structure placement at a specific cell. - public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) => - s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ, Direction = direction }); - - - /// EDITOR / execute_code hook: queue a wall placement at a specific cell. - public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ); - -#endif - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnDestroy() - { - if (_ghost != null) Object.Destroy(_ghost); - if (_ghostMat != null) Object.Destroy(_ghostMat); - } - - protected override void OnUpdate() - { - if (!SystemAPI.TryGetSingletonEntity(out var connection)) - return; - - HandleBuildMode(connection); - - // --- Build-palette toggle (Tab / gamepad Y): Slice 1 HUD declutter — the palette is hidden by default --- - var keyboard = UnityEngine.InputSystem.Keyboard.current; - var gamepad = UnityEngine.InputSystem.Gamepad.current; - bool togglePressed = - (keyboard != null && keyboard.tabKey.wasPressedThisFrame) || - (gamepad != null && gamepad.buttonNorth.wasPressedThisFrame); - if (togglePressed && !PauseMenuController.Open) - BuildPaletteState.TogglePalette(); - - // Hotkey fallback (suppressed while the palette build mode is active). - if (keyboard != null && !BuildPaletteState.Active) - { - foreach (var (key, type) in s_BuildHotkeys) - if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell)) - SendBuild(connection, type, cell.x, cell.y, (byte)0); - } - -#if UNITY_EDITOR - while (s_PendingBuild.Count > 0) - { - var b = s_PendingBuild.Dequeue(); - SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction); - } -#endif - } - - // ---- Palette-driven build mode: ground ghost preview + click-to-place ---- - void HandleBuildMode(Entity connection) - { - byte sel = BuildPaletteState.Selected; - bool justSelected = sel != _lastSelected; // the selecting click must not also place - _lastSelected = sel; - - bool active = BuildPaletteState.Active && !PauseMenuController.Open; - AimPresentation.ForceCursorVisible = active; - if (!active) { HideGhost(); return; } - - var keyboard = UnityEngine.InputSystem.Keyboard.current; - var mouse = UnityEngine.InputSystem.Mouse.current; - - // Cancel build mode (right-click / Esc). - if ((mouse != null && mouse.rightButton.wasPressedThisFrame) || - (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)) - { - BuildPaletteState.Clear(); - HideGhost(); - return; - } - - // Rotate a conveyor's facing ([ / ] or R). - if (keyboard != null) - { - if (keyboard.leftBracketKey.wasPressedThisFrame) - BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 3) % 4); - if (keyboard.rightBracketKey.wasPressedThisFrame || keyboard.rKey.wasPressedThisFrame) - BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 1) % 4); - } - - if (!SystemAPI.TryGetSingleton(out var anchor)) { HideGhost(); return; } - if (_camera == null) _camera = CameraResolver.Resolve(); - if (_camera == null || mouse == null) { HideGhost(); return; } - - // Cursor -> ground -> cell. - UnityEngine.Vector2 sp = mouse.position.ReadValue(); - UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(sp.x, sp.y, 0f)); - if (!AimMath.TryGroundHit((float3)ray.origin, (float3)ray.direction, anchor.GridOrigin.y, out var groundPoint)) - { HideGhost(); return; } - int2 targetCell = BaseGridMath.WorldToCell(anchor, groundPoint); - - // Validity — client mirror of the server check (in-plot, unoccupied, affordable). - bool occupied = false; - foreach (var (ps, xf) in SystemAPI.Query, RefRO>()) - if (math.all(BaseGridMath.WorldToCell(anchor, xf.ValueRO.Position) == targetCell)) { occupied = true; break; } - - int cost = CatalogCost(sel); - byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost); - bool valid = reason == BuildPreviewMath.Valid; - - ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel); - - // Place on a left-click (valid, not the selecting click). - if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame) - SendBuild(connection, sel, targetCell.x, targetCell.y, BuildPaletteState.Direction); - } - - int LedgerOre() - { - if (!SystemAPI.TryGetSingletonEntity(out var le)) return 0; - var buf = SystemAPI.GetBuffer(le); - for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count; - return 0; - } - - int CatalogCost(byte type) - { - if (type == 0 || !SystemAPI.TryGetSingletonEntity(out var ce)) return int.MaxValue; - var cat = SystemAPI.GetBuffer(ce); - for (int i = 0; i < cat.Length; i++) if (cat[i].Type == type) return cat[i].CostAmount; - return int.MaxValue; - } - - // ---- Ground ghost preview: the selected structure's REAL mesh (HudTheme, build-safe serialized refs) - // tinted translucent green/red; falls back to the original procedural cube when no mesh is authored. ---- - void ShowGhost(float3 center, float cellSize, bool valid, byte type) - { - EnsureGhost(); - ApplyGhostMesh(type); - if (_ghostMf.sharedMesh == _cubeMesh) - { - _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f; - _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f); - } - else - { - // preview meshes are authored real-size with a ground pivot (SM_Turret_01 / SM_Wall_01 / SM_Fabricator_01) - _ghost.transform.position = (Vector3)center; - _ghost.transform.localScale = Vector3.one; - } - _ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f); - if (!_ghost.activeSelf) _ghost.SetActive(true); - } - - // Swap the ghost's mesh when the palette selection changes (255 = unset sentinel; no selection is 0 -> cube). - void ApplyGhostMesh(byte type) - { - if (type == _ghostType) return; - _ghostType = type; - var theme = HudTheme.Get(); - Mesh mesh = theme != null ? theme.StructureGhostMesh(type) : null; - if (mesh == null) mesh = _cubeMesh; - _ghostMf.sharedMesh = mesh; - if (_ghostMr.sharedMaterials.Length != mesh.subMeshCount) - { - var mats = new Material[mesh.subMeshCount]; // one translucent mat per submesh so the whole preview tints - for (int i = 0; i < mats.Length; i++) mats[i] = _ghostMat; - _ghostMr.sharedMaterials = mats; - } - } - - void HideGhost() - { - if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false); - } - - void EnsureGhost() - { - if (_ghost != null) return; - var shader = Shader.Find("Sprites/Default"); - if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit"); - _ghostMat = new Material(shader) { color = new Color(0.3f, 1f, 0.45f, 0.35f) }; - _ghost = GameObject.CreatePrimitive(PrimitiveType.Cube); - _ghost.name = "~BuildGhost"; - var col = _ghost.GetComponent(); - if (col != null) Object.Destroy(col); - _ghostMf = _ghost.GetComponent(); - _cubeMesh = _ghostMf.sharedMesh; - _ghostType = 255; - _ghostMr = _ghost.GetComponent(); - _ghostMr.sharedMaterial = _ghostMat; - _ghostMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - _ghostMr.receiveShadows = false; - _ghost.SetActive(false); - } - - - - bool TryGetLocalPlayerCell(out int2 cell) - { - cell = default; - if (!SystemAPI.TryGetSingleton(out var anchor)) - return false; - foreach (var xform in SystemAPI.Query>().WithAll()) - { - cell = BaseGridMath.WorldToCell(anchor, xform.ValueRO.Position); - return true; - } - return false; - } - - void SendBuild(Entity connection, byte type, int cellX, int cellZ, byte direction) - { - var e = EntityManager.CreateEntity(); - EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction }); - EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection }); - } - // (Step 11: the Aether ability-upgrade sender was RETIRED with AbilityUpgradeRequest — boons replaced it.) - } -} diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs.meta deleted file mode 100644 index 2f4de9bf2..000000000 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 765356fd6c5e64c4e9ab588f99d3388f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs deleted file mode 100644 index f92747cdd..000000000 --- a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs +++ /dev/null @@ -1,49 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side boon-pick sender: a static enqueue (the Step-14 3-card modal / execute_code) drained into - /// RPCs. Carries only the option INDEX — the server resolves it against the - /// sender's own authoritative BoonOffer and validates lifecycle/pending, so a stale or forged pick is - /// simply dropped. Statics reset on play-enter (the stale-bridge hazard). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class BoonSendSystem : SystemBase - { - static int s_Pending; - static byte s_PendingIndex; - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() - { - s_Pending = 0; - s_PendingIndex = 0; - } - - /// Queue a boon pick (0/1/2). The HUD card click + execute_code drive this. - public static void PickBoon(byte optionIndex) - { - s_PendingIndex = optionIndex; - s_Pending++; - } - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - while (s_Pending > 0) - { - s_Pending--; - var req = EntityManager.CreateEntity(typeof(BoonPickRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new BoonPickRequest { Index = s_PendingIndex }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta deleted file mode 100644 index ad7427a4f..000000000 --- a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d7e90bc7230614845817b81f0f7b70f0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs deleted file mode 100644 index 9866d6380..000000000 --- a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs +++ /dev/null @@ -1,116 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Client -{ - /// - /// Client-only sender for / RPCs. One-off actions, so - /// RPCs (not per-tick input); the server applies them authoritatively in - /// . Number keys 1-9 equip the Nth EQUIPPABLE item in the local bag - /// (resources are skipped via the catalog); U unequips the weapon. Managed SystemBase because it reads the - /// managed Input System; Input System types are fully qualified and using UnityEngine.InputSystem; is - /// omitted (it defines a colliding PlayerInput type). An #if UNITY_EDITOR static hook drives the same - /// path from execute_code for headless validation. The HUD click-to-equip (HudSystem) is the primary UX; - /// these keys are the reachable fallback. Wire types are unconditional; only this send SYSTEM is gated. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class EquipSendSystem : SystemBase - { - struct PendingEquip { public bool Unequip; public ushort ItemId; public byte Slot; } - - static readonly System.Collections.Generic.Queue s_Pending = - new System.Collections.Generic.Queue(); - - /// EDITOR / execute_code hook: queue an equip of from the bag. - public static void Equip(ushort itemId) => - s_Pending.Enqueue(new PendingEquip { Unequip = false, ItemId = itemId }); - - /// EDITOR / execute_code hook: queue an unequip of (an EquipSlotId). - public static void Unequip(byte slot) => - s_Pending.Enqueue(new PendingEquip { Unequip = true, Slot = slot }); - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - if (!SystemAPI.TryGetSingletonEntity(out var connection)) - return; - - var keyboard = UnityEngine.InputSystem.Keyboard.current; - if (keyboard != null) - { - int n = NumberKeyPressed(keyboard); - if (n >= 0) - { - ushort itemId = NthEquippableBagItem(n); - if (itemId != 0) SendEquip(connection, itemId); - } - if (keyboard.uKey.wasPressedThisFrame) - SendUnequip(connection, EquipSlotId.Weapon); - } - - while (s_Pending.Count > 0) - { - var p = s_Pending.Dequeue(); - if (p.Unequip) SendUnequip(connection, p.Slot); - else SendEquip(connection, p.ItemId); - } - } - - static int NumberKeyPressed(UnityEngine.InputSystem.Keyboard kb) - { - if (kb.digit1Key.wasPressedThisFrame) return 0; - if (kb.digit2Key.wasPressedThisFrame) return 1; - if (kb.digit3Key.wasPressedThisFrame) return 2; - if (kb.digit4Key.wasPressedThisFrame) return 3; - if (kb.digit5Key.wasPressedThisFrame) return 4; - if (kb.digit6Key.wasPressedThisFrame) return 5; - if (kb.digit7Key.wasPressedThisFrame) return 6; - if (kb.digit8Key.wasPressedThisFrame) return 7; - if (kb.digit9Key.wasPressedThisFrame) return 8; - return -1; - } - - /// Resolve the Nth EQUIPPABLE distinct item in the local player's bag (skips resources via the catalog). - ushort NthEquippableBagItem(int n) - { - bool haveDb = SystemAPI.TryGetSingleton(out var db); - foreach (var bag in SystemAPI.Query>().WithAll()) - { - int idx = 0; - for (int i = 0; i < bag.Length; i++) - { - ushort id = bag[i].ItemId; - if (id == 0 || bag[i].Count <= 0) continue; - if (haveDb && db.Value.IsCreated) - { - ref var b = ref db.Value.Value; - if (!b.TryGetItem(id, out var def) || def.EquipSlot >= EquipSlotId.Count) continue; - } - if (idx == n) return id; - idx++; - } - break; - } - return 0; - } - - void SendEquip(Entity connection, ushort itemId) - { - var req = EntityManager.CreateEntity(); - EntityManager.AddComponentData(req, new EquipRequest { ItemId = itemId }); - EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection }); - } - - void SendUnequip(Entity connection, byte slot) - { - var req = EntityManager.CreateEntity(); - EntityManager.AddComponentData(req, new UnequipRequest { Slot = slot }); - EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection }); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta deleted file mode 100644 index 99070f18c..000000000 --- a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7a9bec24b84553746aec834c1b56dfd6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs deleted file mode 100644 index f777e4c16..000000000 --- a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Client -{ - /// - /// Client-only sender for RPCs (move the local player's PERSONAL - /// inventory into the shared base stockpile / global ledger). A one-off action (not per-tick predicted - /// input), so it is an RPC: on the deposit key edge (G = deposit ALL) it creates the request entity - /// targeted at the server connection, and the server applies it authoritatively in - /// . Managed SystemBase because it reads the managed - /// Input System; Input System types are fully qualified and using UnityEngine.InputSystem; is - /// intentionally omitted (that namespace defines a PlayerInput type that collides with - /// ). An editor-only static hook () - /// drives the same path from execute_code for headless validation without a focused Game view. The wire - /// type is unconditional; only this send SYSTEM is build-time managed. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class InventoryDepositSendSystem : SystemBase - { -#if UNITY_EDITOR - struct PendingDeposit { public ushort ItemId; public int Count; } - - static readonly System.Collections.Generic.Queue s_Pending = - new System.Collections.Generic.Queue(); - - /// EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count <= 0 = all of that item). - public static void Deposit(ushort itemId = 0, int count = 0) => - s_Pending.Enqueue(new PendingDeposit { ItemId = itemId, Count = count }); -#endif - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - // Need the server connection to target the RPC; bail (keeping any queued ops) until connected. - if (!SystemAPI.TryGetSingletonEntity(out var connection)) - return; - - var keyboard = UnityEngine.InputSystem.Keyboard.current; - if (keyboard != null && keyboard.gKey.wasPressedThisFrame) - Send(connection, 0, 0); // G -> deposit everything to the base stockpile - -#if UNITY_EDITOR - while (s_Pending.Count > 0) - { - var d = s_Pending.Dequeue(); - Send(connection, d.ItemId, d.Count); - } -#endif - } - - void Send(Entity connection, ushort itemId, int count) - { - var request = EntityManager.CreateEntity(); - EntityManager.AddComponentData(request, new InventoryDepositRequest { ItemId = itemId, Count = count }); - EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection }); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta deleted file mode 100644 index d63db2d3b..000000000 --- a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9298a5924b4920b4db8ff2f48732a662 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs b/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs deleted file mode 100644 index 43cfa3f4d..000000000 --- a/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs +++ /dev/null @@ -1,76 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Client -{ - /// - /// Client-only sender for shared-storage deposit/withdraw RPCs. A - /// one-off action (not per-tick predicted input), so it is an RPC: on an interact key edge (E = - /// deposit, Q = withdraw a default test item) it creates the request entity targeted at the server - /// connection, and the server applies it authoritatively in StorageOpReceiveSystem. Managed - /// SystemBase because it reads the managed Input System. Input System types are fully qualified and - /// using UnityEngine.InputSystem; is intentionally omitted (that namespace defines a - /// PlayerInput type that collides with ). An editor-only - /// static hook (Deposit/Withdraw) drives the same path from execute_code for headless validation - /// without a focused Game view. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class StorageOpSendSystem : SystemBase - { - // Default test item used by the keyboard interact and the parameterless debug hooks. - const ushort DefaultItemId = 1; - const int DefaultCount = 1; - -#if UNITY_EDITOR - struct PendingStorageOp { public byte Op; public ushort ItemId; public int Count; } - - static readonly System.Collections.Generic.Queue s_Pending = - new System.Collections.Generic.Queue(); - - /// EDITOR / execute_code hook: queue a deposit of of . - public static void Deposit(ushort itemId = DefaultItemId, int count = DefaultCount) => - s_Pending.Enqueue(new PendingStorageOp { Op = StorageOp.Deposit, ItemId = itemId, Count = count }); - - /// EDITOR / execute_code hook: queue a withdraw of of . - public static void Withdraw(ushort itemId = DefaultItemId, int count = DefaultCount) => - s_Pending.Enqueue(new PendingStorageOp { Op = StorageOp.Withdraw, ItemId = itemId, Count = count }); -#endif - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - // Need the server connection to target the RPC; bail (keeping any queued ops) until connected. - if (!SystemAPI.TryGetSingletonEntity(out var connection)) - return; - - var keyboard = UnityEngine.InputSystem.Keyboard.current; - if (keyboard != null) - { - if (keyboard.eKey.wasPressedThisFrame) - Send(connection, StorageOp.Deposit, DefaultItemId, DefaultCount); - if (keyboard.qKey.wasPressedThisFrame) - Send(connection, StorageOp.Withdraw, DefaultItemId, DefaultCount); - } - -#if UNITY_EDITOR - while (s_Pending.Count > 0) - { - var op = s_Pending.Dequeue(); - Send(connection, op.Op, op.ItemId, op.Count); - } -#endif - } - - void Send(Entity connection, byte op, ushort itemId, int count) - { - var request = EntityManager.CreateEntity(); - EntityManager.AddComponentData(request, new StorageOpRequest { Op = op, ItemId = itemId, Count = count }); - EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection }); - } - } -} diff --git a/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs.meta b/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs.meta deleted file mode 100644 index 5c3c38a44..000000000 --- a/Assets/_Project/Scripts/Client/HomeBase/StorageOpSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d59f540925fe24a439bad6f7a77907fe \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs index 6c7d76248..5eb983dea 100644 --- a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs +++ b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs @@ -77,16 +77,16 @@ namespace ProjectM.Client var gamepad = UnityEngine.InputSystem.Gamepad.current; var mouse = UnityEngine.InputSystem.Mouse.current; var keyboard = UnityEngine.InputSystem.Keyboard.current; - bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)) && !BuildPaletteState.Active; - // MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged projectile demoted to right-click / pad left-trigger. Both suppressed while placing a build (like dash/old fire). - bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame)) && !BuildPaletteState.Active; - bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active; + bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)); + // MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged demoted to right-click / pad LT. + bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame)); + bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame)); // LANTERN 4-socket kit bindings: keyboard 1..4 (+ gamepad RT/LB/RB); socket 0 also fires on the - // legacy primary (right-click / pad LT). Suppressed while placing a build. - bool socket0Pressed = firePressed || ((keyboard != null && keyboard.digit1Key.wasPressedThisFrame) && !BuildPaletteState.Active); - bool socket1Pressed = ((keyboard != null && keyboard.digit2Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active; - bool socket2Pressed = ((keyboard != null && keyboard.digit3Key.wasPressedThisFrame) || (gamepad != null && gamepad.leftShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active; - bool socket3Pressed = ((keyboard != null && keyboard.digit4Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active; + // legacy primary (right-click / pad LT). Build-palette suppression removed 2026-08-07. + bool socket0Pressed = firePressed || ((keyboard != null && keyboard.digit1Key.wasPressedThisFrame)); + bool socket1Pressed = ((keyboard != null && keyboard.digit2Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightTrigger.wasPressedThisFrame)); + bool socket2Pressed = ((keyboard != null && keyboard.digit3Key.wasPressedThisFrame) || (gamepad != null && gamepad.leftShoulder.wasPressedThisFrame)); + bool socket3Pressed = ((keyboard != null && keyboard.digit4Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightShoulder.wasPressedThisFrame)); float2 rightStick = float2.zero; bool gamepadActive = false; diff --git a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs deleted file mode 100644 index 37262116f..000000000 --- a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side meta-purchase sender: a static enqueue (the Step-14 base meta-shop panel / execute_code) drained - /// into RPCs. Carries only the upgrade ID — the tier is server-computed and the - /// server re-validates everything (Staging gate, class mask, MaxTier, prereq, Aether affordability), so a stale - /// or forged request is simply dropped. A real QUEUE (unlike the single-slot boon pick) — two rapid clicks on - /// two different shop rows must both arrive. Statics reset on play-enter (the stale-bridge hazard). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class MetaSpendSendSystem : SystemBase - { - static readonly Queue s_Queue = new(); - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() => s_Queue.Clear(); - - /// Queue a permanent-upgrade purchase by catalog id. The shop row click + execute_code drive this. - public static void RequestPurchase(byte upgradeId) => s_Queue.Enqueue(upgradeId); - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - while (s_Queue.Count > 0) - { - var req = EntityManager.CreateEntity(typeof(MetaSpendRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new MetaSpendRequest { UpgradeId = s_Queue.Dequeue() }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta deleted file mode 100644 index 06dc91328..000000000 --- a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9f3197b4ede6dea41a6c4c93ffa51b42 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index 390c965af..0fde265e7 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -80,36 +80,8 @@ namespace ProjectM.Client } } - // Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only. - if (SystemAPI.TryGetSingleton(out var runAudio)) - { - int sec = -1; - if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0 - && SystemAPI.TryGetSingleton(out var antime) && antime.ServerTick.IsValid) - { - int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick); - if (tl > 0) sec = tl / 60 + 1; - } - if (sec > 0 && sec != _lastCountdownSec && sec <= 3) - _ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx); - _lastCountdownSec = sec; - - bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom - && runAudio.CurrentRoomType == RoomTypeId.Boss; - if (!inBossRoom) - { - _bossRoared = false; // re-arm for the next boss room - } - else if (!_bossRoared) - { - foreach (var _ in SystemAPI.Query>().WithAll()) - { - _ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx); - _bossRoared = true; - break; - } - } - } + // 2026-08-07 audit purge: the 3-2-1 launch countdown beeps and the boss-arrival roar keyed off + // RunInfo.Lifecycle / RoomTypeId. Both went with the run FSM and the boss. } // ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ---- diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs index 012606a1f..8d6391f6c 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs @@ -109,13 +109,8 @@ namespace ProjectM.Client if (_cam.transform.position.x > 500f) { key = 1; // arid default in the expedition region - if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging) - switch (ri.CurrentBiome) - { - case RoomBiomeId.Meadow: key = 0; break; - case RoomBiomeId.Cavern: key = 2; break; - case RoomBiomeId.Blight: key = 3; break; - } + // 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome. The room + // biomes went with the run FSM; the expedition region keeps its single ambient set. } return key; } diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs index cb533fd51..f2bf661f5 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs @@ -152,15 +152,8 @@ namespace ProjectM.Client if (_cam.transform.position.x > 500f) { key = 1; // arid default - if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging) - { - switch (ri.CurrentBiome) - { - case RoomBiomeId.Meadow: key = 0; break; - case RoomBiomeId.Cavern: key = 2; break; - case RoomBiomeId.Blight: key = 3; break; - } - } + // 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome, which went + // with the run FSM. The expedition region keeps its single motion set. } if (key != _biomeKey) { diff --git a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs deleted file mode 100644 index 0449c7574..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.UIElements; - -namespace ProjectM.Client -{ - /// - /// The choice-of-3 boon modal (RoomReward) — extracted from into its own client-only, - /// observe-only presentation in . Owns its own - /// runtime UIDocument sharing (sortingOrder 55) so it composes into the - /// same UITK panel + event dispatcher as the HUD (50) / markers (48) / onboarding (60). Reads the local player's - /// replicated + the blob; card clicks enqueue through - /// . Built lazily on first show. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class BoonModalHudSystem : SystemBase - { - GameObject _go; - UIDocument _doc; - bool _built; - - VisualElement _boonModal, _boonCardRow; - int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for - bool _boonModalBuilt; - - protected override void OnStartRunning() - { - if (_go != null) return; - MenuUi.EnsureEventSystem(); - _go = new GameObject("~HUDBoonModal"); - _doc = _go.AddComponent(); - _doc.panelSettings = MenuUi.LoadPanelSettings(); - _doc.sortingOrder = 55; - } - - protected override void OnDestroy() - { - if (_go != null) Object.Destroy(_go); - } - - protected override void OnUpdate() - { - if (_doc == null) return; - var root = _doc.rootVisualElement; - if (root == null) return; // panel not initialised yet (next frame) - if (!_built) - { - root.style.position = Position.Absolute; - root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; - root.pickingMode = PickingMode.Ignore; // never eat game-world clicks - _built = true; - } - - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - - BoonOffer localOffer = default; - bool hasOffer = false; - foreach (var off in SystemAPI.Query>().WithAll()) - { - localOffer = off.ValueRO; - hasOffer = true; - break; - } - BlobAssetReference boonPool = default; - if (SystemAPI.TryGetSingleton(out var bcat)) - boonPool = bcat.Value; - // Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside - // the reward window. - UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1 - && haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool); - } - - void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference pool) - { - if (!show || !pool.IsCreated) - { - if (_boonModal != null) _boonModal.style.display = DisplayStyle.None; - _boonShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_boonModalBuilt) - { - BuildBoonModal(root); - _boonModalBuilt = true; - } - - // Rebuild the three cards only when the offer actually changes (a new room's deal). - // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive - // rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel. - int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16)); - if (_boonShownFor != sig) - { - _boonCardRow.Clear(); - ref var defs = ref pool.Value; - for (byte k = 0; k < 3; k++) - { - byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0; - int idx = BoonMath.FindDef(ref defs, id); - string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); - string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; - byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100; - byte pick = k; // capture a COPY into the closure, never the loop variable - var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); - card.style.width = 200; - card.style.height = StyleKeyword.Auto; // long descs grow the card - card.style.minHeight = 96; - // Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic). - var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f) - : weight <= 30 ? new Color(0.65f, 0.50f, 1f) - : weight <= 60 ? new Color(0.45f, 0.95f, 0.55f) - : new Color(1f, 1f, 1f, 0.30f); - MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f); - card.style.marginLeft = 8; - card.style.marginRight = 8; - card.style.whiteSpace = WhiteSpace.Normal; - _boonCardRow.Add(card); - } - _boonShownFor = sig; - } - _boonModal.style.display = DisplayStyle.Flex; - } - - void BuildBoonModal(VisualElement root) - { - _boonModal = new VisualElement { pickingMode = PickingMode.Ignore }; - _boonModal.style.position = Position.Absolute; - _boonModal.style.left = 0; _boonModal.style.right = 0; - _boonModal.style.top = 0; _boonModal.style.bottom = 0; - _boonModal.style.alignItems = Align.Center; - _boonModal.style.justifyContent = Justify.Center; - _boonModal.style.display = DisplayStyle.None; - - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 18; box.style.paddingRight = 18; - box.style.paddingTop = 14; box.style.paddingBottom = 16; - box.style.alignItems = Align.Center; - - var title = new Label("ROOM CLEARED — CHOOSE A BOON"); - title.style.color = new Color(0.6f, 1f, 0.7f); - title.style.fontSize = 18; - title.style.unityFontStyleAndWeight = FontStyle.Bold; - title.style.marginBottom = 12; - box.Add(title); - - _boonCardRow = new VisualElement(); - _boonCardRow.style.flexDirection = FlexDirection.Row; - box.Add(_boonCardRow); - - _boonModal.Add(box); - root.Add(_boonModal); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta deleted file mode 100644 index 4f29e033c..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 90c2dc6391260794dbdf466b2f8887e5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs deleted file mode 100644 index a3c5f33d1..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.UIElements; - -namespace ProjectM.Client -{ - /// - /// DR-046 base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore) — - /// extracted from into their own client-only, observe-only presentation - /// in . Owns its own runtime UIDocument sharing - /// (sortingOrder 54). Recomputes the local class + ore/bio/aether + the - /// Staging gate + the RoomExplore portal proximity locally; clicks enqueue through - /// / / . - /// NOTE (behavior-preserving): the class/prep Staging gate reproduces the original's FULL condition — it also - /// requires the + buffer to be present (the panels - /// were gated on the same metaShow boolean as the meta shop). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class ClassPrepPortalHudSystem : SystemBase - { - GameObject _go; - UIDocument _doc; - bool _built; - - VisualElement _classPanel, _prepPanel, _prepRowsHost; - Label _classTitle, _prepTitle, _portalPrompt; - Button _classWarBtn, _classRangerBtn; - bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt; - int _classShownFor, _prepShownFor; - - protected override void OnStartRunning() - { - if (_go != null) return; - MenuUi.EnsureEventSystem(); - _go = new GameObject("~HUDClassPrepPortal"); - _doc = _go.AddComponent(); - _doc.panelSettings = MenuUi.LoadPanelSettings(); - _doc.sortingOrder = 54; - } - - protected override void OnDestroy() - { - if (_go != null) Object.Destroy(_go); - } - - protected override void OnUpdate() - { - if (_doc == null) return; - var root = _doc.rootVisualElement; - if (root == null) return; // panel not initialised yet (next frame) - if (!_built) - { - root.style.position = Position.Absolute; - root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; - root.pickingMode = PickingMode.Ignore; // never eat game-world clicks - _built = true; - } - - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - - // Resources from the ledger (last entry per type wins, matching the core loop). - int aether = 0, ore = 0, bio = 0; - if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) - { - var buf = SystemAPI.GetBuffer(ledgerE); - for (int i = 0; i < buf.Length; i++) - { - var en = buf[i]; - if (en.ItemId == ResourceId.Aether) aether = en.Count; - else if (en.ItemId == ResourceId.Ore) ore = en.Count; - else if (en.ItemId == ResourceId.Biomass) bio = en.Count; - } - } - - // Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only). - byte localClass = ClassTraits.WarriorClass; - bool haveLocalPlayer = false; - foreach (var fr in SystemAPI.Query>().WithAll()) - { - localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted) - haveLocalPlayer = true; - break; - } - - // Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as - // the meta shop, which requires the meta catalog + tier buffer to exist. - DynamicBuffer metaRecord = default; - bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer - && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated - && SystemAPI.TryGetSingletonBuffer(out metaRecord, true); - - UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging) - UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging) - UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore) - } - - void UpdateClassPanel(bool show, byte classId) - { - if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; } - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; } - int sig = classId + 1; - if (_classShownFor != sig) - { - bool ranger = classId == ClassTraits.RangerClass; - _classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓"; - _classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER"; - _classWarBtn.SetEnabled(ranger); - _classRangerBtn.SetEnabled(!ranger); - _classShownFor = sig; - } - _classPanel.style.display = DisplayStyle.Flex; - } - - void BuildClassPanel(VisualElement root) - { - _classPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _classPanel.style.position = Position.Absolute; - _classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22); - _classPanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - MenuUi.Round(box, 10); - box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; - _classTitle = new Label("CLASS"); - _classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14; - _classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8; - box.Add(_classTitle); - _classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass)); - _classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn); - _classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass)); - box.Add(_classRangerBtn); - _classPanel.Add(box); root.Add(_classPanel); - } - - void UpdatePrepPanel(bool show, int ore, int bio, int aether) - { - if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; } - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; } - uint boughtMask = 0; - foreach (var mods in SystemAPI.Query>().WithAll()) - { - for (int m = 0; m < mods.Length; m++) - { - uint sid = mods[m].SourceId; - if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan) - boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase)); - } - break; - } - int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101; - if (sig == 0) sig = 1; - if (_prepShownFor != sig) - { - _prepRowsHost.Clear(); - for (int i = 0; i < PrepCatalog.Count; i++) - { - var r = PrepCatalog.Rows[i]; - int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore; - bool bought = (boughtMask & (uint)(1 << r.Id)) != 0; - string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore"; - string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName); - byte buyId = r.Id; - var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId)); - row.style.width = 240; row.style.marginBottom = 4; - row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft; - row.SetEnabled(!bought && have >= r.Cost); - _prepRowsHost.Add(row); - } - _prepShownFor = sig; - } - _prepPanel.style.display = DisplayStyle.Flex; - } - - static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed" - : id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage"; - - void BuildPrepPanel(VisualElement root) - { - _prepPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _prepPanel.style.position = Position.Absolute; - _prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45); - _prepPanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - MenuUi.Round(box, 10); - box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; - _prepTitle = new Label("PREP LOADOUT (lasts the run)"); - _prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14; - _prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8; - box.Add(_prepTitle); - _prepRowsHost = new VisualElement(); box.Add(_prepRowsHost); - _prepPanel.Add(box); root.Add(_prepPanel); - } - - void UpdatePortalPrompt(RunInfo runInfo, bool haveRun) - { - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; } - bool show = false, inRange = false; - if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore - && SystemAPI.TryGetSingleton(out var anchor)) - { - show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range - float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1)); - foreach (var lt in SystemAPI.Query>().WithAll()) - { - inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange; - if (inRange) - { - var kb = UnityEngine.InputSystem.Keyboard.current; - if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact(); - } - break; - } - _portalPrompt.text = inRange - ? "PRESS E TO LEAVE — the haul comes home" - : "ROOM CLEAR — reach the glowing portal to move on"; - } - _portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; - } - - void BuildPortalPrompt(VisualElement root) - { - _portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter); - _portalPrompt.style.position = Position.Absolute; - _portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240; - _portalPrompt.pickingMode = PickingMode.Ignore; - _portalPrompt.style.display = DisplayStyle.None; - root.Add(_portalPrompt); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta deleted file mode 100644 index 1781d7f1f..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 32e011d68689e89488879377a7fb4c3a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index e25e4c153..f2f24c173 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -225,7 +225,7 @@ namespace ProjectM.Client bool isEnemy = SystemAPI.HasComponent(entity); uint windup = isEnemy && SystemAPI.HasComponent(entity) ? SystemAPI.GetComponent(entity).WindUpUntilTick : 0u; bool isLocalPlayer = entity == _localPlayer; - bool isStructure = SystemAPI.HasComponent(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem + const bool isStructure = false; // structures deleted 2026-08-07 (audit purge) if (_cache.TryGetValue(entity, out var prev)) { diff --git a/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs b/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs index 12f929760..ad9f55894 100644 --- a/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs @@ -148,23 +148,10 @@ namespace ProjectM.Client } PruneUnseen(_barrelLights); - // ---- portal light (RoomExplore only) ---- - bool portalOn = false; - if (SystemAPI.TryGetSingleton(out var runInfo) - && runInfo.Lifecycle == RunLifecycle.RoomExplore - && SystemAPI.TryGetSingleton(out var anchor)) - { - portalOn = true; - var pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1)); - if (_portalLight == null) _portalLight = Rent(); - float baseI = cfg != null ? cfg.PortalIntensity : 3.5f; - float amp = cfg != null ? cfg.PortalPulseAmp : 1.2f; - _portalLight.color = cfg != null ? cfg.PortalColor : new Color(0.55f, 0.95f, 1f, 1f); - _portalLight.range = cfg != null ? cfg.PortalRange : 13f; - _portalLight.intensity = baseI + amp * Mathf.Sin(UnityEngine.Time.time * 4f); - _portalLight.transform.position = new Vector3(pos.x, pos.y + 2.2f, pos.z); - } - if (!portalOn && _portalLight != null) { Return(_portalLight); _portalLight = null; } + // 2026-08-07 audit purge: the room-exit portal light keyed off RunInfo.Lifecycle == RoomExplore. + // Portals went with the run FSM; release any pooled light so nothing leaks. + if (_portalLight != null) { Return(_portalLight); _portalLight = null; } + // ---- impact flashes ---- float now = UnityEngine.Time.time; diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs index da7f7e107..5f7dcd1ea 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs @@ -85,7 +85,6 @@ namespace ProjectM.Client dt = dt, prevPos = _prevPos, seen = seen, - isLunging = SystemAPI.GetComponentLookup(true), }; Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos @@ -111,7 +110,7 @@ namespace ProjectM.Client { public FastAnimatorParameter moveX, moveZ, speed, isAttacking, isDead, isHit, isHitHeavy; public float el, reactSeconds, staggerSeconds, staggerDamage; // 07-21 hit-react (reactSeconds 0 = off) - [Unity.Collections.ReadOnly] public ComponentLookup isLunging; // A7: a lunge has no AttackWindup; OR it in so the boss/Charger attack anim plays during the committed lunge + public float dt; public NativeParallelHashMap prevPos; @@ -149,7 +148,7 @@ namespace ProjectM.Client float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); - bool attacking = windup.WindUpUntilTick != 0 || (isLunging.HasComponent(e) && isLunging.IsComponentEnabled(e)); // A7: the committed lunge (which zeroes AttackWindup) still animates as an attack + bool attacking = windup.WindUpUntilTick != 0; // the IsLunging OR went with the Charger purge (2026-08-07) // B3: the corpse window — Health.Current is replicated, so <=0 IS the death read. A corpse // plays the Death state and nothing else (no jog, no frozen attack). diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs index 64e76bad3..95b987953 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs @@ -98,7 +98,6 @@ namespace ProjectM.Client EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); // Local player (strike-beep proximity gate). _localPlayer = Entity.Null; @@ -122,7 +121,6 @@ namespace ProjectM.Client Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; _dangerSeen.Clear(); _enemySeen.Clear(); - bool bossRoom = SystemAPI.TryGetSingleton(out var dangerRi) && dangerRi.Lifecycle == RunLifecycle.InRoom && dangerRi.CurrentRoomType == RoomTypeId.Boss; // A7: in a Boss room the Charger-kind enemy IS the boss (adds are swarmers) if (serverTick.IsValid) { @@ -151,26 +149,16 @@ namespace ProjectM.Client } _prevWindup[entity] = until; - // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). - bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity); - bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph + if (until == 0u) continue; - if (until == 0u && !lunging) continue; - - float intensity; - if (lunging) - { - intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears - } - else - { - var untilTick = new Unity.NetCode.NetworkTick(until); - if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed - int remaining = untilTick.TicksSince(serverTick); - // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for - // any windup length (fixes the Charger plateauing early under the old hard-coded 22). - float windupDur = isBoss ? Tuning.BossSlamWindupTicks : math.max(1f, tele.ValueRO.WindupTicks); // A7: ramp over the boss's real slam wind-up - intensity = math.saturate(1f - remaining / windupDur); + var untilTick = new Unity.NetCode.NetworkTick(until); + if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed + int remaining = untilTick.TicksSince(serverTick); + // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for + // any windup length. + float windupDur = math.max(1f, tele.ValueRO.WindupTicks); + float intensity = math.saturate(1f - remaining / windupDur); + {; // Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to // enemies near the local player (the danger cone already proves it's winding up to strike). @@ -211,33 +199,9 @@ namespace ProjectM.Client _dangerZones[entity] = go; } float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); - if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel - if (isBoss && !lunging) - { - // A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell - // matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE). - BuildDangerMesh(go.GetComponent().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity); - } - else if (isBoss) - { - // B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup + - // travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge. - BuildDangerMesh(go.GetComponent().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity); - } - else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter) - { - // MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim - // LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the - // shot nears so the player reads the line to dodge/dash across it. - float laneLen = 12f; - if (SystemAPI.HasComponent(entity)) - { - var ss = SystemAPI.GetComponent(entity); - laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f); - } - BuildLaneMesh(go.GetComponent().sharedMesh, laneLen, 0.28f, intensity); - } - else BuildDangerMesh(go.GetComponent().sharedMesh, coneRange, 0.7f, intensity); + // 2026-08-07 audit purge: the boss radial-slam ring, the boss lunge wedge and the Spitter aim + // lane are gone with BossState / IsLunging / SpitterState. One enemy kind, one melee wedge. + BuildDangerMesh(go.GetComponent().sharedMesh, coneRange, 0.7f, intensity); float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation); var tr = go.transform; tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f; diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyMarkerSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyMarkerSystem.cs index a63d039d0..9a78bb84a 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyMarkerSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyMarkerSystem.cs @@ -76,7 +76,7 @@ namespace ProjectM.Client // Collect living enemies within range (nearest-capped by MaxMarkers). _positions.Clear(); float rangeSq = FeelConfig.EnemyMarkerRange * FeelConfig.EnemyMarkerRange; - foreach (var lt in SystemAPI.Query>().WithAll().WithNone()) + foreach (var lt in SystemAPI.Query>().WithAll().WithNone()) { float3 p = lt.ValueRO.Position; if (haveLocal && math.distancesq(p, localPos) > rangeSq) continue; diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index d6e8cb959..3a76942b5 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -142,134 +142,10 @@ namespace ProjectM.Client bool haveTick = SystemAPI.TryGetSingleton(out var nt); int huskCount = _huskQuery.CalculateEntityCount(); - // ---- Macro banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ---- - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging; - if (haveRun) - { - var col = onRun ? new Color(1f, 0.8f, 0.4f) : new Color(0.45f, 0.9f, 0.7f); - _phaseText.text = onRun ? "ON EXPEDITION" : "AT BASE"; - _phaseText.style.color = col; - _banner.style.borderBottomColor = col; - RetintPanel(_banner, PanelDark); - } - else - { - _phaseText.text = ""; - } - - // ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ---- - var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat) - bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin; - SystemAPI.TryGetSingleton(out var obj); - if (haveRun) - { - switch (runInfo.Lifecycle) - { - case RunLifecycle.Staging: - // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. - _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; - _locationText.style.color = new Color(0.55f, 0.85f, 1f); - break; - case RunLifecycle.Launching: - { - // Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick - // near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math. - int secs = 0; - if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton(out var ntime) - && ntime.ServerTick.IsValid) - { - int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick); - if (ticksLeft > 0) secs = ticksLeft / 60 + 1; - } - _locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort"; - _locationText.style.color = new Color(1f, 0.9f, 0.4f); - break; - } - case RunLifecycle.InRoom: - { - string room = "ROOM " + (runInfo.CurrentRoom + 1) + "/" + runInfo.RoomCount - + " " + RoomTypeLabel(runInfo.CurrentRoomType); - _locationText.text = obj.State == ExpeditionObjectiveState.Active - ? room + " - " + obj.Remaining + " enemies remaining" - : room + " - clear it to advance"; - _locationText.style.color = new Color(1f, 0.8f, 0.4f); - break; - } - case RunLifecycle.RoomReward: - _locationText.text = "ROOM CLEARED - choose your boon"; - _locationText.style.color = new Color(0.5f, 1f, 0.6f); - break; - case RunLifecycle.RoomExplore: // Phase 0: without this case the RoomReward text stuck through the loot window - _locationText.text = "ROOM CLEAR - grab the loot, take the portal to move on"; - _locationText.style.color = new Color(0.5f, 1f, 0.6f); - break; - case RunLifecycle.RouteSelect: - _locationText.text = "CHOOSE YOUR PATH"; - _locationText.style.color = new Color(0.55f, 0.85f, 1f); - break; - case RunLifecycle.Returning: - _locationText.text = "RETURNING HOME..."; - _locationText.style.color = new Color(0.7f, 0.9f, 1f); - break; - } - } - else - { - _locationText.text = ""; - } - - - - // The clickable READY panel (Staging/Launching). Counts are the replicated send-to-all PlayerReady flags. - int rTotal = 0, rReady = 0; - bool localReady = false; - int launchSecs = 0; - bool readyShow = haveRun - && (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching); - if (readyShow) - { - foreach (var pr in SystemAPI.Query>().WithAll()) - { - rTotal++; - if (pr.ValueRO.Value != 0) rReady++; - } - foreach (var pr in SystemAPI.Query>().WithAll()) - localReady = pr.ValueRO.Value != 0; - if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0 - && SystemAPI.TryGetSingleton(out var lnt) && lnt.ServerTick.IsValid) - { - int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick); - if (tl > 0) launchSecs = tl / 60 + 1; - } - } - UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs); - - // Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe) - // in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region - // siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so - // the fraction reads true directly. - bool bossAlive = false; - float bossHp = 0f, bossMax = 0f; - if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) - { - foreach (var (bhq, tele, blt) in - SystemAPI.Query, RefRO, RefRO>().WithAll()) - { - if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers - if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy) - if (bhq.ValueRO.Max > bossMax) - { - bossMax = bhq.ValueRO.Max; - bossHp = bhq.ValueRO.Current; - bossAlive = bhq.ValueRO.Current > 0f; - } - } - } - UpdateBossBar(bossAlive, bossHp, bossMax); - - // Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates). - UpdateRunDepth(haveRun ? runInfo : default, haveRun); + // 2026-08-07 audit purge: the macro run banner, the location sub-line, the ready-check panel, the + // boss bar and the run-depth dots were all driven by RunInfo / ExpeditionObjective / PlayerReady — + // the superseded base/expedition FSM. All deleted; the HUD is now vitals + threat + resources + the + // ability bar (its own system). // ---- Resources (feed palette affordability) ---- @@ -307,23 +183,6 @@ namespace ProjectM.Client RetintPanel(_threatPanel, PanelDark); } - // ---- Build palette + control hints (bottom-center) ---- - UpdatePalette(aether, ore, bio, onExpedition); - bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt; - bool buildActive = paletteOpen && BuildPaletteState.Active; - if (buildActive) - { - byte scheme = AimPresentation.Scheme; - if (!_hintBuilt || _hintScheme != scheme) RebuildHints(scheme); - _hintBar.style.display = DisplayStyle.Flex; - } - else - { - _hintBar.style.display = DisplayStyle.None; - } - // Build-mode discovery chip: a subtle "Tab/Y — BUILD" hint when the palette is hidden at base (Slice 1). - _buildDiscoveryChip.style.display = (!onExpedition && !BuildPaletteState.PaletteOpen) - ? DisplayStyle.Flex : DisplayStyle.None; // ---- Per-player vitals ---- bool found = false; @@ -348,7 +207,7 @@ namespace ProjectM.Client break; } - _doc.rootVisualElement.style.display = (found || haveRun) ? DisplayStyle.Flex : DisplayStyle.None; + _doc.rootVisualElement.style.display = found ? DisplayStyle.Flex : DisplayStyle.None; // ---- Low-health vignette + hurt flash (full-screen) ---- _flash = HudVisualMath.DecayFlash(_flash, dt); @@ -389,51 +248,6 @@ namespace ProjectM.Client _vignette.style.display = DisplayStyle.None; _downed.style.display = DisplayStyle.None; } - // ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ---- - var invKb = UnityEngine.InputSystem.Keyboard.current; - if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen; - if (_invOpen && found) - { - EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); - bool haveItemDb = SystemAPI.TryGetSingleton(out var itemDb); - _invPanel.style.display = DisplayStyle.Flex; - - _invList.Clear(); - int shown = 0; - foreach (var bag in SystemAPI.Query>() - .WithAll()) - { - for (int i = 0; i < bag.Length; i++) - { - var slot = bag[i]; - if (slot.ItemId == 0 || slot.Count <= 0) continue; - AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count, - IsEquippable(haveItemDb, itemDb, slot.ItemId)); - shown++; - } - break; - } - if (shown == 0) - _invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft)); - - _equipList.Clear(); - foreach (var slots in SystemAPI.Query>() - .WithAll()) - { - for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++) - { - ushort id = slots[s].ItemId; - string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id)); - AddEquipRow(s, label, id != 0); - } - break; - } - } - else - { - _invPanel.style.display = DisplayStyle.None; - } } // ---- per-frame helpers ---- @@ -446,94 +260,9 @@ namespace ProjectM.Client - // LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only). - static bool IsPaletteType(byte type) => type != StructureType.Pylon; - void UpdatePalette(int aether, int ore, int bio, bool onExpedition) - { - if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity(out var catE)) - { - var cat = SystemAPI.GetBuffer(catE); - for (int i = 0; i < cat.Length; i++) - if (IsPaletteType(cat[i].Type)) - AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); - _paletteBuilt = true; - } - if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } - - bool showPalette = !onExpedition && BuildPaletteState.PaletteOpen; - _paletteRow.style.display = showPalette ? DisplayStyle.Flex : DisplayStyle.None; - foreach (var kv in _palette) - { - var item = kv.Value; - int have = item.CostRes == ResourceId.Aether ? aether : item.CostRes == ResourceId.Biomass ? bio : ore; - bool affordable = have >= item.CostAmount; - bool selected = BuildPaletteState.Selected == kv.Key; - item.Root.style.opacity = affordable ? 1f : 0.5f; - item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f); - if (item.Icon != null) - item.Icon.style.unityBackgroundImageTintColor = affordable ? AetherCyan : new Color(0.5f, 0.55f, 0.6f); - MenuUi.Border(item.Root, selected ? MenuUi.Accent : SlotIdleBorder, selected ? 2 : 1); - item.Root.style.backgroundColor = selected ? SlotSelBg : SlotIdleBg; - if (item.Glow != null) item.Glow.style.opacity = selected ? 0.6f : 0f; - } - } - - void AddPaletteItem(byte type, int cost, byte costRes) - { - if (type == 0 || _palette.ContainsKey(type)) return; - var theme = HudTheme.Get(); - - var root = new VisualElement(); - root.style.width = 86; - root.style.marginLeft = 4; root.style.marginRight = 4; - root.style.paddingTop = 8; root.style.paddingBottom = 6; - root.style.alignItems = Align.Center; - root.style.backgroundColor = SlotIdleBg; - root.pickingMode = PickingMode.Position; - MenuUi.Round(root, 6); - MenuUi.Border(root, SlotIdleBorder, 1); - - // selection glow: a soft Synty glow filling the slot behind everything, opacity toggled on select. - var glow = new VisualElement(); - glow.style.position = Position.Absolute; - glow.style.left = 3; glow.style.right = 3; glow.style.top = 4; glow.style.bottom = 4; - glow.pickingMode = PickingMode.Ignore; - glow.style.opacity = 0f; - if (theme != null && theme.Glow != null) - { - glow.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Glow)); - glow.style.unityBackgroundImageTintColor = AetherCyan; - glow.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover)); - } - root.Add(glow); - - var iconEl = HudUi.Icon(theme != null ? theme.StructureIcon(type) : null, 44, AetherCyan); - root.Add(iconEl); - - var nameLabel = HudUi.Text(StructureName(type), 12, MenuUi.TextCol, TextAnchor.MiddleCenter); - nameLabel.style.marginTop = 2; - root.Add(nameLabel); - - var costRow = new VisualElement(); - costRow.style.flexDirection = FlexDirection.Row; - costRow.style.alignItems = Align.Center; - costRow.style.marginTop = 2; - costRow.pickingMode = PickingMode.Ignore; - var costIcon = HudUi.Icon(ResourceSprite(theme, costRes), 14, ResourceTint(costRes)); - costIcon.style.marginRight = 3; - costRow.Add(costIcon); - var costLabel = HudUi.Display(cost.ToString(), 13, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); - costRow.Add(costLabel); - root.Add(costRow); - - byte t = type; - root.RegisterCallback(_ => - BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t)); - - _paletteRow.Add(root); - _palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl }; - } + // 2026-08-07 audit purge: AddPaletteItem built one build-palette slot (icon, cost row, selection glow, + // click-to-select). The build palette went with the structures layer. void RebuildHints(byte scheme) { @@ -583,11 +312,7 @@ namespace ProjectM.Client BuildThreat(root); BuildMacro(root); BuildResources(root); - BuildPaletteRow(root); - BuildHintBar(root); - BuildDiscoveryChip(root); BuildDowned(root); - BuildInventory(root); } void BuildVignette(VisualElement root) @@ -827,119 +552,9 @@ namespace ProjectM.Client - void BuildInventory(VisualElement root) - { - _invPanel = HudUi.Panel(PanelDark); - _invPanel.style.position = Position.Absolute; - _invPanel.style.right = 40; _invPanel.style.bottom = 40; - _invPanel.style.minWidth = 224; - _invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14; - _invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10; - _invPanel.style.alignItems = Align.FlexStart; - _invPanel.pickingMode = PickingMode.Ignore; - - var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft); - header.style.marginBottom = 6; - _invPanel.Add(header); - - _invList = new VisualElement(); - _invList.pickingMode = PickingMode.Ignore; - _invPanel.Add(_invList); - - var equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft); - equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4; - _invPanel.Add(equipHeader); - - _equipList = new VisualElement(); - _equipList.pickingMode = PickingMode.Ignore; - _invPanel.Add(_equipList); - - var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft); - hint.style.marginTop = 8; - _invPanel.Add(hint); - - _invPanel.style.display = DisplayStyle.None; - root.Add(_invPanel); - } - - void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable) - { - var row = new VisualElement(); - row.style.flexDirection = FlexDirection.Row; - row.style.justifyContent = Justify.SpaceBetween; - row.style.minWidth = 196; - row.style.marginTop = 2; - row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft)); - row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight)); - if (equippable) - { - row.pickingMode = PickingMode.Position; - ushort id = itemId; - row.RegisterCallback(_ => EquipSendSystem.Equip(id)); - } - else row.pickingMode = PickingMode.Ignore; - _invList.Add(row); - } - - static string ItemName(bool haveDb, ItemDatabase db, ushort id) - { - if (haveDb && db.Value.IsCreated) - { - ref var blob = ref db.Value.Value; - if (blob.TryGetItem(id, out var def)) return def.Name.ToString(); - } - if (id == ResourceId.Aether) return "Aether"; - if (id == ResourceId.Ore) return "Ore"; - if (id == ResourceId.Biomass) return "Biomass"; - return "Item " + id; - } - - static Color ItemTint(ushort id) - { - if (id == ResourceId.Aether) return AetherCyan; - if (id == ResourceId.Ore) return OreAmber; - if (id == ResourceId.Biomass) return BioGreen; - return new Color(0.85f, 0.85f, 0.9f); - } - static bool IsEquippable(bool haveDb, ItemDatabase db, ushort id) - { - if (!haveDb || !db.Value.IsCreated) return false; - ref var b = ref db.Value.Value; - return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count; - } - - static string SlotName(byte slot) - { - switch (slot) - { - case EquipSlotId.Weapon: return "Weapon"; - case EquipSlotId.Armor: return "Armor"; - case EquipSlotId.Trinket: return "Trinket"; - case EquipSlotId.Tool: return "Tool"; - default: return "Slot " + slot; - } - } - - void AddEquipRow(byte slot, string label, bool occupied) - { - var row = new VisualElement(); - row.style.flexDirection = FlexDirection.Row; - row.style.justifyContent = Justify.SpaceBetween; - row.style.minWidth = 196; - row.style.marginTop = 2; - row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft)); - if (occupied) - { - row.pickingMode = PickingMode.Position; - byte s = slot; - row.RegisterCallback(_ => EquipSendSystem.Unequip(s)); - row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight)); - } - else row.pickingMode = PickingMode.Ignore; - _equipList.Add(row); - } - - + // 2026-08-07 audit purge: BuildInventory / AddInvRow / ItemName / ItemTint / IsEquippable / SlotName / + // AddEquipRow drove the personal-inventory + equipment strip. That layer was already PAUSED in CLAUDE.md + // and went with the shell. static Color ResourceTint(byte resId) => resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber; @@ -954,213 +569,10 @@ namespace ProjectM.Client - static string StructureName(byte type) - { - switch (type) - { - case StructureType.Wall: return "Wall"; - case StructureType.Pylon: return "Pylon"; - default: return "?"; - } - } - - // ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ==== - - - static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]" - : roomType == RoomTypeId.Elite ? "[ELITE]" - : roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]"; - - - - - - // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind - // to the authoritative RouteOpt* bytes) ---- - - - - - - - - - - - - - - - - - // ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ---- - - void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs) - { - if (!show) - { - if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None; - _readyShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_readyPanelBuilt) - { - BuildReadyPanel(root); - _readyPanelBuilt = true; - } - - bool launching = runInfo.Lifecycle == RunLifecycle.Launching; - int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0); - if (_readyShownFor != sig) - { - _readyTitle.text = launching - ? "LAUNCHING IN " + launchSecs - : "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY"; - _readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f); - _readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]"; - _readyPipRow.Clear(); - for (int i = 0; i < total; i++) - { - var pip = new VisualElement(); - pip.style.width = 14; pip.style.height = 14; - pip.style.marginLeft = 3; pip.style.marginRight = 3; - MenuUi.Round(pip, 7f); - pip.style.backgroundColor = i < ready - ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f); - _readyPipRow.Add(pip); - } - _readyShownFor = sig; - } - _readyPanel.style.display = DisplayStyle.Flex; - } - - void BuildReadyPanel(VisualElement root) - { - _readyPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _readyPanel.style.position = Position.Absolute; - _readyPanel.style.left = 0; _readyPanel.style.right = 0; - _readyPanel.style.bottom = 170; // clear of the build palette row + hint bar - _readyPanel.style.alignItems = Align.Center; - _readyPanel.style.display = DisplayStyle.None; - - var box = new VisualElement { pickingMode = PickingMode.Position }; - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 18; box.style.paddingRight = 18; - box.style.paddingTop = 10; box.style.paddingBottom = 12; - box.style.alignItems = Align.Center; - - _readyTitle = new Label("EXPEDITION"); - _readyTitle.style.fontSize = 16; - _readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold; - box.Add(_readyTitle); - - _readyPipRow = new VisualElement(); - _readyPipRow.style.flexDirection = FlexDirection.Row; - _readyPipRow.style.justifyContent = Justify.Center; - _readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8; - box.Add(_readyPipRow); - - _readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady); - box.Add(_readyBtn); - - _readyPanel.Add(box); - root.Add(_readyPanel); - } - - // ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ---- - - void UpdateBossBar(bool alive, float hp, float max) - { - if (!alive || max <= 0f) - { - if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_bossBarBuilt) - { - BuildBossBar(root); - _bossBarBuilt = true; - } - HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max)); - _bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max); - _bossPanel.style.display = DisplayStyle.Flex; - } - - void BuildBossBar(VisualElement root) - { - _bossPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _bossPanel.style.position = Position.Absolute; - _bossPanel.style.left = 0; _bossPanel.style.right = 0; - _bossPanel.style.top = 168; - _bossPanel.style.alignItems = Align.Center; - _bossPanel.style.display = DisplayStyle.None; - - var col = HudUi.Group(Align.Center); - _bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter); - col.Add(_bossText); - var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill); - bar.style.marginTop = 4; - col.Add(bar); - _bossPanel.Add(col); - root.Add(_bossPanel); - } - - // ---- run-depth dots (visible through the whole run; the current room pulses bigger) ---- - - void UpdateRunDepth(RunInfo runInfo, bool haveRun) - { - bool show = haveRun && runInfo.RoomCount > 0 - && (runInfo.Lifecycle == RunLifecycle.InRoom - || runInfo.Lifecycle == RunLifecycle.RoomReward - || runInfo.Lifecycle == RunLifecycle.RouteSelect); - if (!show) - { - if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None; - _depthShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_depthBuilt) - { - _depthPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _depthPanel.style.position = Position.Absolute; - _depthPanel.style.left = 0; _depthPanel.style.right = 0; - _depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168) - _depthPanel.style.flexDirection = FlexDirection.Row; - _depthPanel.style.justifyContent = Justify.Center; - root.Add(_depthPanel); - _depthBuilt = true; - } - int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3; - if (_depthShownFor != sig) - { - _depthPanel.Clear(); - for (int i = 0; i < runInfo.RoomCount; i++) - { - bool current = i == runInfo.CurrentRoom; - bool done = i < runInfo.CurrentRoom; - var dot = new VisualElement { pickingMode = PickingMode.Ignore }; - float size = current ? 12f : 8f; - dot.style.width = size; dot.style.height = size; - dot.style.marginLeft = 3; dot.style.marginRight = 3; - dot.style.alignSelf = Align.Center; - MenuUi.Round(dot, size * 0.5f); - dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f) - : done ? new Color(0.55f, 0.85f, 1f, 0.55f) - : new Color(1f, 1f, 1f, 0.16f); - _depthPanel.Add(dot); - } - _depthShownFor = sig; - } - _depthPanel.style.display = DisplayStyle.Flex; - } + // 2026-08-07 audit purge: StructureName, RoomTypeLabel, the branching route map, the ready panel, the + // boss presence bar and the run-depth dots all belonged to the superseded base/expedition loop and are + // deleted along with RunInfo / PlayerReady / BossState / StructureCatalog. Recover from git if the + // roguelite spine returns. diff --git a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs deleted file mode 100644 index 32de8a67c..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.UIElements; - -namespace ProjectM.Client -{ - /// - /// The Staging-only permanent-upgrade shop (meta shop) — extracted from into its own - /// client-only, observe-only presentation in . - /// Owns its own runtime UIDocument sharing (sortingOrder 51). Recomputes - /// its inputs locally: the local class from the replicated - /// (), Aether from the buffer, siege from - /// , and the Staging gate from . Row clicks enqueue through - /// — the server re-validates everything. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class MetaShopHudSystem : SystemBase - { - GameObject _go; - UIDocument _doc; - bool _built; - - VisualElement _metaPanel, _metaRowsHost; - Label _metaShopTitle; - bool _metaShopBuilt; - int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for - - protected override void OnStartRunning() - { - if (_go != null) return; - MenuUi.EnsureEventSystem(); - _go = new GameObject("~HUDMetaShop"); - _doc = _go.AddComponent(); - _doc.panelSettings = MenuUi.LoadPanelSettings(); - _doc.sortingOrder = 51; - } - - protected override void OnDestroy() - { - if (_go != null) Object.Destroy(_go); - } - - protected override void OnUpdate() - { - if (_doc == null) return; - var root = _doc.rootVisualElement; - if (root == null) return; // panel not initialised yet (next frame) - if (!_built) - { - root.style.position = Position.Absolute; - root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; - root.pickingMode = PickingMode.Ignore; // never eat game-world clicks - _built = true; - } - - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - - // Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop). - int aether = 0; - if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) - { - var buf = SystemAPI.GetBuffer(ledgerE); - for (int i = 0; i < buf.Length; i++) - if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count; - } - - // Local class derives from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is - // server-only); tiers from the replicated MetaTierState record on the director ghost. - byte localClass = ClassTraits.WarriorClass; - bool haveLocalPlayer = false; - foreach (var fr in SystemAPI.Query>().WithAll()) - { - localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted) - haveLocalPlayer = true; - break; - } - - bool metaShow = false; - BlobAssetReference metaPool = default; - DynamicBuffer metaRecord = default; - if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer - && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated - && SystemAPI.TryGetSingletonBuffer(out metaRecord, true)) - { - metaPool = metaCat.Value; - metaShow = true; - } - UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord); - } - - void UpdateMetaShop(bool show, byte classId, int aether, - BlobAssetReference pool, DynamicBuffer record) - { - if (!show) - { - if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None; - _metaShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_metaShopBuilt) - { - BuildMetaShop(root); - _metaShopBuilt = true; - } - - // Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows). - int sig = classId * 131 ^ aether * 31; - for (int i = 0; i < record.Length; i++) - sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3); - if (sig == 0) sig = 1; - if (_metaShownFor != sig) - { - _metaRowsHost.Clear(); - _metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR") - + " PERMANENT UPGRADES - AETHER " + aether; - ref var defs = ref pool.Value; - byte classBit = BoonMath.MaskFor(classId); - for (int d = 0; d < defs.Defs.Length; d++) - { - if ((defs.Defs[d].ClassMask & classBit) == 0) continue; - byte id = defs.Defs[d].Id; - byte owned = MetaMath.TierOf(record, classId, id); - if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop) - bool maxed = owned >= defs.Defs[d].MaxTier; - int cost = MetaMath.CostForTier(in defs.Defs[d], owned); - string label = defs.Defs[d].Name.ToString() - + (maxed ? " MAXED" : " - " + cost + " Aether") - + "\n" + defs.Defs[d].Desc.ToString(); - byte buyId = id; // closure copy, never the loop variable - var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); - row.style.width = 290; - row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix) - row.style.paddingTop = 6; row.style.paddingBottom = 6; - row.style.marginBottom = 4; - row.style.whiteSpace = WhiteSpace.Normal; - row.style.unityTextAlign = TextAnchor.MiddleLeft; - row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway - // Owned-tier pips (replaces the "[2/5]" text — reads at a glance). - var pipRow = new VisualElement { pickingMode = PickingMode.Ignore }; - pipRow.style.flexDirection = FlexDirection.Row; - pipRow.style.marginTop = 3; - for (int p = 0; p < defs.Defs[d].MaxTier; p++) - { - var tp = new VisualElement { pickingMode = PickingMode.Ignore }; - tp.style.width = 9; tp.style.height = 9; - tp.style.marginRight = 3; - MenuUi.Round(tp, 4.5f); - tp.style.backgroundColor = p < owned ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.14f); - pipRow.Add(tp); - } - row.Add(pipRow); - _metaRowsHost.Add(row); - } - _metaShownFor = sig; - } - _metaPanel.style.display = DisplayStyle.Flex; - } - - void BuildMetaShop(VisualElement root) - { - _metaPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _metaPanel.style.position = Position.Absolute; - _metaPanel.style.right = 12; - _metaPanel.style.top = Length.Percent(22); - _metaPanel.style.alignItems = Align.FlexEnd; - _metaPanel.style.display = DisplayStyle.None; - - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 12; box.style.paddingRight = 12; - box.style.paddingTop = 10; box.style.paddingBottom = 10; - - _metaShopTitle = new Label("PERMANENT UPGRADES"); - _metaShopTitle.style.color = MenuUi.Accent; - _metaShopTitle.style.fontSize = 14; - _metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold; - _metaShopTitle.style.marginBottom = 8; - box.Add(_metaShopTitle); - - _metaRowsHost = new VisualElement(); - box.Add(_metaRowsHost); - - _metaPanel.Add(box); - root.Add(_metaPanel); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta deleted file mode 100644 index 95d6ce937..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2551683f48286ae41980c5bb4e48d6e1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs index a363d6093..cc000d825 100644 --- a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs @@ -78,30 +78,9 @@ namespace ProjectM.Client // ---- pick the mix from replicated state (defaults = quiet staging bed) ---- float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f; - if (SystemAPI.TryGetSingleton(out var run)) - { - switch (run.Lifecycle) - { - case RunLifecycle.Launching: - tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell - break; - case RunLifecycle.InRoom: - bool boss = run.CurrentRoomType == RoomTypeId.Boss; - tBass = boss ? 0.75f : 0.65f; - tPad = 0.40f; - tArp = boss ? 0.80f : 0.65f; - tPulse = boss ? 0.75f : 0.35f; - break; - case RunLifecycle.RoomReward: - case RunLifecycle.RouteSelect: - tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull - break; - case RunLifecycle.Returning: - tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution - break; - // Staging keeps the defaults. - } - } + // 2026-08-07 audit purge: the mix used to key off RunInfo.Lifecycle (staging / launching / in-room / + // boss / returning). With the run FSM deleted the bed holds its defaults; re-key it off LANTERN's + // descent state when Phase 2 lands. float dt = SystemAPI.Time.DeltaTime * FadePerSecond; _vBass = Mathf.MoveTowards(_vBass, tBass, dt); diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs b/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs deleted file mode 100644 index 73acb30ff..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs +++ /dev/null @@ -1,34 +0,0 @@ -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Live-tunable knobs + build-safe prefab references for — the Phase 1.5b - /// per-room cosmetic dressing scatter. Mirrors the / - /// bridge idiom: a MonoBehaviour in the gameplay scene with a static the client - /// presentation system reads. Prefab arrays are serialized scene references (never Resources.Load — that is - /// build-stripped). Absent config or an empty set = no dressing (the feature is soft-off). - /// - public sealed class RoomDressingConfig : MonoBehaviour - { - public static RoomDressingConfig Instance; - - [Header("Master")] - public bool Enabled = true; - [Min(0)] public int CountPerRoom = 26; - [Min(0.1f)] public float ScaleMin = 0.85f; - [Min(0.1f)] public float ScaleMax = 1.35f; - - [Header("Per-biome cosmetic prop sets (RoomBiomeId: Meadow/Arid/Cavern/Blight)")] - public GameObject[] Meadow; - public GameObject[] Arid; - public GameObject[] Cavern; - public GameObject[] Blight; - - void OnEnable() { Instance = this; } - void OnDisable() { if (Instance == this) Instance = null; } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() { Instance = null; } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs.meta deleted file mode 100644 index e34a7e4ba..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomDressingConfig.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4ef612b567d6f6746bc03f764274740e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs deleted file mode 100644 index 51dd99e2f..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs +++ /dev/null @@ -1,172 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Phase 1.5b (ground bundle) — client-only, observe-only PER-ROOM DRESSING SCATTER. A managed - /// in that OBSERVES replicated - /// and instantiates biome-flavoured cosmetic ground props (pebbles, tufts, bones, - /// mushrooms) inside the room's ACTUAL shape — the same authority the server - /// scatter uses, on a DISTINCT hash stream so dressing never co-locates with nodes (0x0DE) or clutter - /// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the - /// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on - /// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim. - /// - /// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre - /// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to - /// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in. - /// - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class RoomDressingSystem : SystemBase - { - Transform _root; - uint _activeKey; - - // Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and - // torn down each room change, these shared assets persist until OnDestroy). - Material _decalMat; - Mesh[] _decalMeshes; - MaterialPropertyBlock _decalMpb; - static readonly int DecalColorId = Shader.PropertyToID("_Color"); - - protected override void OnStartRunning() - { - if (_root == null) _root = new GameObject("~RoomDressing").transform; - } - - protected override void OnDestroy() - { - if (_root != null) Object.Destroy(_root.gameObject); - if (_decalMat != null) Object.Destroy(_decalMat); - if (_decalMeshes != null) - for (int i = 0; i < _decalMeshes.Length; i++) - if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]); - } - - protected override void OnUpdate() - { - if (!ScenePolicy.IsGameplayScene()) return; - if (_root == null) return; - - var cfg = RoomDressingConfig.Instance; - bool inRoom = SystemAPI.TryGetSingleton(out var ri) - && (ri.Lifecycle == RunLifecycle.InRoom - || ri.Lifecycle == RunLifecycle.RoomReward - || ri.Lifecycle == RunLifecycle.RoomExplore); - bool on = cfg != null && cfg.Enabled && cfg.CountPerRoom > 0; - if (!inRoom || !on || !SystemAPI.TryGetSingleton(out var anchor)) - { - Clear(); - return; - } - - uint key = RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E5u); - if (key == _activeKey) return; - Clear(); - Build(in ri, in anchor, cfg); - _activeKey = key; - } - - void Clear() - { - if (_activeKey == 0 || _root == null) { _activeKey = 0; return; } - for (int i = _root.childCount - 1; i >= 0; i--) - Object.Destroy(_root.GetChild(i).gameObject); - _activeKey = 0; - } - - void Build(in RunInfo ri, in BaseAnchor anchor, RoomDressingConfig cfg) - { - GameObject[] set = ri.CurrentBiome switch - { - RoomBiomeId.Meadow => cfg.Meadow, - RoomBiomeId.Cavern => cfg.Cavern, - RoomBiomeId.Blight => cfg.Blight, - _ => cfg.Arid, - }; - if (set == null || set.Length == 0) return; - - float3 baseCenter = BaseGridMath.PlotCenter(anchor); - byte subSlot = (byte)(ri.CurrentRoom & 1); - float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot); - float3 portal = RegionMath.ExpeditionPortalPos(baseCenter, subSlot); - - var map = RunMapMath.Generate(ri.RunSeed); - var node = map.NodeAt(RunMap.NodeId(ri.CurrentRoom, ri.CurrentCol)); - var plan = RoomLayoutMath.Plan(node, ri.CurrentRoom, ri.RoomCount); - var rng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E55u) | 1u); - - int count = cfg.CountPerRoom; - for (int i = 0; i < count; i++) - { - float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); - for (int attempt = 0; attempt < 4; attempt++) - { - bool nearLanding = math.distancesq(pos.xz, origin.xz) < 9f; // party lands at the origin - bool nearPortal = math.distancesq(pos.xz, portal.xz) < 6.25f; // keep the exit beacon clear - if (!nearLanding && !nearPortal) break; - pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); - } - var prefab = set[rng.NextInt(0, set.Length)]; - if (prefab == null) continue; - var go = Object.Instantiate(prefab, _root, false); - go.transform.SetPositionAndRotation( - new Vector3(pos.x, 0f, pos.z), // terrain y=0 (origin.y is the CC capsule plane, 1 u up) - Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f)); - go.transform.localScale = Vector3.one * rng.NextFloat(cfg.ScaleMin, cfg.ScaleMax); - foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col); - foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb); - } - - // Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop - // scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing. - if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0) - { - EnsureDecalAssets(); - var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u); - for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++) - SpawnArenaScar(origin, i, ref srng); - } - } - - void EnsureDecalAssets() - { - if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal"); - if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock(); - if (_decalMeshes == null) - { - _decalMeshes = new Mesh[4]; - _decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11); - _decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23); - _decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31); - _decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47); - } - } - - void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng) - { - var go = new GameObject("ArenaScar"); - go.transform.SetParent(_root, false); - go.AddComponent().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)]; - var mr = go.AddComponent(); - mr.sharedMaterial = _decalMat; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - mr.receiveShadows = false; - mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; - _decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor); - mr.SetPropertyBlock(_decalMpb); - float ang = rng.NextFloat(0f, math.PI * 2f); - float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens - float scale = rng.NextFloat(1.4f, 3.0f); - go.transform.SetPositionAndRotation( - new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad), - Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f)); - go.transform.localScale = new Vector3(scale, 1f, scale); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs.meta deleted file mode 100644 index 692b3e35b..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6eb0d1fcbd0a69244842b4eb2fbcd49f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs deleted file mode 100644 index 3a855294d..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs +++ /dev/null @@ -1,102 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using UnityEngine; -using static ProjectM.Client.FeedbackFx; - -namespace ProjectM.Client -{ - /// - /// DR-046 — client-only, observe-only presentation of the room-exit PORTAL made visible. A managed - /// in that OBSERVES replicated - /// and never mutates the sim. During the loot window it shows a glowing cyan - /// pillar (or the authored effect when wired) at the client-derived portal position - /// so the player has an unmistakable "go here to continue" target; hidden whenever the run isn't in RoomExplore. - /// Position resolves through the SAME authority the HUD prompt uses, so - /// the beacon and the "PRESS E" range always agree. Extracted from CombatFeedbackSystem (owns its own FX-root + - /// beacon material); no Entity-keyed cache. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class RoomPortalBeaconSystem : SystemBase - { - Transform _fxRoot; - GameObject _portalFx; // Phase 1: authored portal effect (VFXConfig.Portal) replacing the procedural pillar when wired - Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat) - GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore - - protected override void OnStartRunning() - { - if (_fxRoot != null) return; - _fxRoot = new GameObject("~RoomPortalBeaconFX").transform; - _portalMat = MakeParticleMaterial(); - _portalMat.name = "RoomPortal"; - _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.85f); // DR-046: HDR cyan portal glow (Phase 0: tamed — 2.6/3.4 bloomed to a white blob) - } - - protected override void OnDestroy() - { - if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); - if (_portalMat != null) Object.Destroy(_portalMat); - } - - protected override void OnUpdate() - { - UpdatePortalBeacon(); - } - - // DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the - // client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt - // alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while"). - // Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position - // resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E" - // range always agree. - void UpdatePortalBeacon() - { - if (_fxRoot == null || _portalMat == null) return; - bool inExplore = SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore; - if (!inExplore || !SystemAPI.TryGetSingleton(out var anchor)) - { - if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); - if (_portalFx != null && _portalFx.activeSelf) _portalFx.SetActive(false); - return; - } - float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1)); - // Phase 1: prefer the authored portal effect (VFXConfig.Portal, PolygonParticleFX) over the - // procedural pillar; the pillar remains the asset-free fallback. - var vfx = VFXConfig.Instance; - if (vfx != null && vfx.Portal != null) - { - if (_portalFx == null) - { - _portalFx = Object.Instantiate(vfx.Portal, _fxRoot, false); - _portalFx.name = "~RoomPortalFx"; - } - _portalFx.transform.position = new Vector3(pos.x, 0f, pos.z); // terrain y=0 (pos.y is the capsule plane) - if (!_portalFx.activeSelf) _portalFx.SetActive(true); - if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); - return; - } - if (_portalBeacon == null) - { - _portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder); - _portalBeacon.name = "~RoomPortalBeacon"; - var col = _portalBeacon.GetComponent(); if (col != null) Object.Destroy(col); // cosmetic only - _portalBeacon.transform.SetParent(_fxRoot, false); - var mr = _portalBeacon.GetComponent(); - mr.sharedMaterial = _portalMat; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - mr.receiveShadows = false; - } - if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true); - float t = (float)SystemAPI.Time.ElapsedTime; - float breathe = 0.5f + 0.5f * math.sin(t * 3.5f); - var tr = _portalBeacon.transform; - // Cylinder is 2u tall in local space -> scale.y=2.2 gives a 4.4u pillar; lift the centre so the base sits - // on the TERRAIN (y=0) — pos.y is the CC capsule-center plane (GridOrigin.y=1), 1 u above the ground. - tr.position = new Vector3(pos.x, 2.2f, pos.z); - tr.localScale = new Vector3(0.9f + 0.12f * breathe, 2.2f, 0.9f + 0.12f * breathe); - _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.45f + 0.3f * breathe); // glow throb (beacon-only mat; Phase 0: tamed + slimmed — the fat 6u pillar bloomed to a white egg swallowing the prompt) - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta deleted file mode 100644 index 5dfa01fb9..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 587c50c122f52954da052232f28a6052 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs deleted file mode 100644 index 938e22fad..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.UIElements; - -namespace ProjectM.Client -{ - /// - /// The drawn branching route map (RouteSelect) — extracted from into its own client-only, - /// observe-only presentation in . Owns its own - /// runtime UIDocument sharing (sortingOrder 55). The map is regenerated - /// client-side from RunInfo.RunSeed for DISPLAY only; the clickable next-layer nodes bind to the - /// authoritative RouteOpt* bytes (never the regen) via . Also owns the - /// client-local visited-path trace (nodeIds; reset per RunSeed) that lights walked edges. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class RouteMapHudSystem : SystemBase - { - GameObject _go; - UIDocument _doc; - bool _built; - - VisualElement _routePanel; - Label _routeTitle; - bool _routePanelBuilt; - VisualElement _routeMapHost; // node circles + Painter2D edges - int _routeMapSig; // (seed, room, col, options) signature the map was drawn for - readonly List _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed - uint _routeVisitedSeed; - - protected override void OnStartRunning() - { - if (_go != null) return; - MenuUi.EnsureEventSystem(); - _go = new GameObject("~HUDRouteMap"); - _doc = _go.AddComponent(); - _doc.panelSettings = MenuUi.LoadPanelSettings(); - _doc.sortingOrder = 55; - } - - protected override void OnDestroy() - { - if (_go != null) Object.Destroy(_go); - } - - protected override void OnUpdate() - { - if (_doc == null) return; - var root = _doc.rootVisualElement; - if (root == null) return; // panel not initialised yet (next frame) - if (!_built) - { - root.style.position = Position.Absolute; - root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; - root.pickingMode = PickingMode.Ignore; // never eat game-world clicks - _built = true; - } - - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - UpdateRoutePanel(haveRun ? runInfo : default); - - // Client-local path trace for the route map (nodeIds visited this run; display-only). - if (haveRun && runInfo.RunSeed != _routeVisitedSeed) - { - _routeVisited.Clear(); - _routeVisitedSeed = runInfo.RunSeed; - } - if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom) - { - int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol); - if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode); - } - } - - // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind - // to the authoritative RouteOpt* bytes) ---- - - const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f; - - static Vector2 MapNodePos(int layer, int col, byte layerWidth) - { - float x = MapPad + layer * MapStrideX; - float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY; - return new Vector2(x, y); - } - - static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B" - : t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C"; - - static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f) - : t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f) - : t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f); - - void UpdateRoutePanel(RunInfo runInfo) - { - // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). - bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0; - if (!show) - { - if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; - _routeMapSig = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_routePanelBuilt) - { - BuildRoutePanel(root); - _routePanelBuilt = true; - } - - int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31 - ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16) - ^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20); - if (sig == 0) sig = 1; - if (_routeMapSig != sig) - { - RebuildRouteMap(runInfo); - _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; - _routeMapSig = sig; - } - _routePanel.style.display = DisplayStyle.Flex; - } - - void BuildRoutePanel(VisualElement root) - { - _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _routePanel.style.position = Position.Absolute; - _routePanel.style.left = 0; _routePanel.style.right = 0; - _routePanel.style.top = 0; _routePanel.style.bottom = 0; - _routePanel.style.alignItems = Align.Center; - _routePanel.style.justifyContent = Justify.Center; - _routePanel.style.display = DisplayStyle.None; - - var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 18; box.style.paddingRight = 18; - box.style.paddingTop = 12; box.style.paddingBottom = 12; - box.style.alignItems = Align.Center; - - _routeTitle = new Label("CHOOSE YOUR PATH"); - _routeTitle.style.color = new Color(0.55f, 0.85f, 1f); - _routeTitle.style.fontSize = 16; - _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; - _routeTitle.style.marginBottom = 10; - box.Add(_routeTitle); - - _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore }; - _routeMapHost.style.position = Position.Relative; - box.Add(_routeMapHost); - - var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13, - MenuUi.SubCol, TextAnchor.MiddleCenter); - cap.style.marginTop = 10; - box.Add(cap); - - _routePanel.Add(box); - root.Add(_routePanel); - } - - void RebuildRouteMap(RunInfo runInfo) - { - _routeMapHost.Clear(); - var map = RunMapMath.Generate(runInfo.RunSeed); - _routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize; - _routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize; - - // Edges under the nodes (Painter2D); walked segments glow, the rest are faint. - var edges = new VisualElement { pickingMode = PickingMode.Ignore }; - edges.style.position = Position.Absolute; - edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0; - var mapCopy = map; - var visited = new List(_routeVisited); - edges.generateVisualContent += ctx => - { - var p = ctx.painter2D; - p.lineWidth = 2f; - var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f); - for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++) - for (int col = 0; col < mapCopy.LayerWidths[layer]; col++) - { - var node = mapCopy.Node(layer, col); - if (node.NextMask == 0) continue; - var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]); - for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++) - { - if ((node.NextMask & (1 << j)) == 0) continue; - var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]); - bool walked = visited.Contains(RunMap.NodeId(layer, col)) - && visited.Contains(RunMap.NodeId(layer + 1, j)); - p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f); - p.BeginPath(); - p.MoveTo(a + c); - p.LineTo(b + c); - p.Stroke(); - } - } - }; - _routeMapHost.Add(edges); - - int nextLayer = runInfo.CurrentRoom + 1; - for (int layer = 0; layer < map.LayerCount; layer++) - for (int col = 0; col < map.LayerWidths[layer]; col++) - { - var node = map.Node(layer, col); - bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol; - bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col)); - byte opt = 255; - if (layer == nextLayer) - { - if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0; - else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1; - else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2; - } - _routeMapHost.Add(MakeMapNode(node.RoomType, - MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom)); - } - } - - VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past) - { - bool clickable = optionIndex != 255; - var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore }; - n.style.position = Position.Absolute; - n.style.left = pos.x; n.style.top = pos.y; - n.style.width = MapNodeSize; n.style.height = MapNodeSize; - MenuUi.Round(n, MapNodeSize * 0.5f); - var c = RoomColor(roomType); - float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f; - var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA); - n.style.backgroundColor = restBg; - MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f), - isCurrent || clickable ? 2.5f : 1.2f); - var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore }; - lbl.style.unityTextAlign = TextAnchor.MiddleCenter; - lbl.style.flexGrow = 1; - lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f); - lbl.style.fontSize = 15; - lbl.style.unityFontStyleAndWeight = FontStyle.Bold; - n.Add(lbl); - if (clickable) - { - byte pick = optionIndex; // closure copy, never the loop variable - n.RegisterCallback(_ => RouteSendSystem.PickRoute(pick)); - n.RegisterCallback(_ => - n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); - n.RegisterCallback(_ => n.style.backgroundColor = restBg); - } - return n; - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta deleted file mode 100644 index a5d84fe6b..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e2330bd90a6294442959883258d476b4 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs deleted file mode 100644 index 87384c754..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; -using UnityEngine; -using static ProjectM.Client.FeedbackFx; - -namespace ProjectM.Client -{ - /// - /// EB-1 — client-only WORLD JUICE for player-built structures taking damage + dying ("loses have weight"). A - /// managed in that OBSERVES replicated state and - /// never mutates the sim: it edge-detects each structure ghost's [GhostField] Health.Current — a decrease - /// spawns a small amber chip (camera-SILENT so a siege's many hits never clamp the shake), and a destruction - /// (an HP<=0 edge OR a despawn) spawns a LOUD red-orange burst + camera punch. A PROXIMITY GATE suppresses the - /// destruction burst unless the structure was near the local player, so the base->expedition RegionRelevancy - /// despawn (every base structure drops from this client at once) stays SILENT. De-duped: a structure fires its - /// death burst AT MOST once (the HP<=0 edge sets DeathFired so the prune-cleanup skips it; the server destroys - /// a structure the same tick it hits 0, so the prune is usually the path that fires). CombatFeedbackSystem - /// suppresses structures, so this is the SOLE structure cue. Procedural particles + SFX (mirrors - /// WorldFeedbackSystem; self-contained). Never destroys a ghost (GhostDespawnSystem owns despawn); prunes the - /// cache EVERY frame (no [RequireMatchingQueriesForUpdate] — else a cache entry leaks per kill). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class StructureFeedbackSystem : SystemBase - { - struct Cache { public float Hp; public float3 Pos; public bool DeathFired; } - - readonly Dictionary _cache = new(); - readonly HashSet _seen = new(); - readonly List _stale = new(); - - Transform _fxRoot; - ParticleSystem _chipFx; - ParticleSystem _deathFx; - AudioClip _chipClip; - AudioClip _deathClip; - - protected override void OnCreate() - { - _chipClip = MakeClip("struct_chip", 700f, 500f, 0.05f, 0.30f); - _deathClip = MakeClip("struct_death", 220f, 60f, 0.35f, 0.55f); - } - - protected override void OnStartRunning() - { - if (_fxRoot != null) return; - _fxRoot = new GameObject("~StructureFeedbackFX").transform; - var mat = MakeParticleMaterial(); - _chipFx = MakeBurst(_fxRoot, "StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256, 0.3f, 0.18f, 0.15f); - _deathFx = MakeBurst(_fxRoot, "StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512, 0.3f, 0.18f, 0.15f); - } - - protected override void OnDestroy() - { - if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); - } - - protected override void OnUpdate() - { - if (!StructureFeelConfig.Enabled) { _cache.Clear(); return; } - - EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); - - bool haveLocal = false; - float3 localPos = default; - foreach (var xf in SystemAPI.Query>().WithAll()) - { - localPos = xf.ValueRO.Position; - haveLocal = true; - } - float rangeSq = StructureFeelConfig.ProximityRange * StructureFeelConfig.ProximityRange; - - _seen.Clear(); - foreach (var (health, xf, e) in - SystemAPI.Query, RefRO>().WithAll().WithEntityAccess()) - { - _seen.Add(e); - float cur = health.ValueRO.Current; - float3 pos = xf.ValueRO.Position; - bool nearby = haveLocal && math.distancesq(pos, localPos) <= rangeSq; - - if (_cache.TryGetValue(e, out var prev)) - { - if (cur <= 0f && prev.Hp > 0f && !prev.DeathFired) - { - if (nearby) FireDeath(pos); - _cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = true }; - continue; - } - if (cur < prev.Hp - 0.001f && cur > 0f && nearby) - { - EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.7f, StructureFeelConfig.ChipBurstCount, StructureFeelConfig.DamageTint); - PlayClip(_chipClip, (Vector3)pos, StructureFeelConfig.ChipSfxVolume); - } - } - _cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = _cache.TryGetValue(e, out var c2) && c2.DeathFired }; - } - - // Prune: a despawn = destroyed (or a region-transit drop). Proximity-gated so the +1000 base->expedition - // despawn stays silent; de-duped against an HP<=0 edge that already fired this structure's death. - if (_cache.Count != _seen.Count) - { - _stale.Clear(); - foreach (var kv in _cache) - if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); - for (int i = 0; i < _stale.Count; i++) - { - var c = _cache[_stale[i]]; - if (!c.DeathFired && haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq) - FireDeath(c.Pos); - _cache.Remove(_stale[i]); - } - } - } - - void FireDeath(float3 pos) - { - EmitTinted(_deathFx, (Vector3)pos + Vector3.up * 0.6f, StructureFeelConfig.DeathBurstCount, StructureFeelConfig.DeathTint); - PlayClip(_deathClip, (Vector3)pos, StructureFeelConfig.DeathSfxVolume); - PrototypeCameraRig.PunchFov(StructureFeelConfig.DeathFovKick, 110f); - PrototypeCameraRig.AddShake(StructureFeelConfig.DeathShake); - } - - // ---- procedural particles + SFX (mirrors WorldFeedbackSystem; self-contained) ---- - - - - - - - - - - - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta deleted file mode 100644 index 50177ed1f..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 61153a58a80eb0542bbdc62085cce81b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs deleted file mode 100644 index d460d51ff..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs +++ /dev/null @@ -1,47 +0,0 @@ -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// EB-1 — static live-tunable knobs for (structure damage chips + - /// destruction bursts). A presentation-only bridge (mirrors WorldFeelConfig); reset on play-enter via - /// so poked values never leak across fast-enter-playmode sessions. - /// Read only on the main thread by the managed feedback system, never from Burst. - /// - public static class StructureFeelConfig - { - public static bool Enabled = true; - - /// A despawn farther than this from the local player does NOT fire a death burst — so the - /// base->expedition RegionRelevancy despawn (all base structures drop at once) stays silent. - public static float ProximityRange = 45f; - - public static int ChipBurstCount = 8; - public static int DeathBurstCount = 40; - public static float ChipSfxVolume = 0.25f; - public static float DeathSfxVolume = 0.6f; - - // A LOUD, low-frequency punch is reserved for a structure DEATH only; per-chip feedback is camera-silent so - // a wave of hits never sustains a nauseating shake (AddShake clamps cumulatively, PunchFov takes a max). - public static float DeathFovKick = 5.5f; - public static float DeathShake = 0.35f; - - public static Color DamageTint = new Color(2.4f, 1.4f, 0.4f); // amber HDR spark on a hit - public static Color DeathTint = new Color(3.0f, 0.7f, 0.25f); // red-orange HDR loss burst - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetDefaults() - { - Enabled = true; - ProximityRange = 45f; - ChipBurstCount = 8; - DeathBurstCount = 40; - ChipSfxVolume = 0.25f; - DeathSfxVolume = 0.6f; - DeathFovKick = 5.5f; - DeathShake = 0.35f; - DamageTint = new Color(2.4f, 1.4f, 0.4f); - DeathTint = new Color(3.0f, 0.7f, 0.25f); - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta deleted file mode 100644 index daf0ff099..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c868d6648bec9fd4199c44fcf8330326 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs index 1a3ac30cf..60570a7d9 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs @@ -39,28 +39,11 @@ namespace ProjectM.Client float expDen = cfg != null ? cfg.ExpeditionFogDensity : 0.04f; Color expAmb = cfg != null ? cfg.ExpeditionAmbientSky : new Color(0.02f, 0.045f, 0.07f, 1f); - // Per-room murk flavors (cosmetic code consts; all stay inside the deep-sea band — hue shifts are - // subtle, value stays dark so dynamic lights keep carrying readability). - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - if (haveRun && runInfo.Lifecycle != ProjectM.Simulation.RunLifecycle.Staging) - { - switch (runInfo.CurrentBiome) - { - case ProjectM.Simulation.RoomBiomeId.Meadow: // kelp shallows: a touch greener - expFog = new Color(0.02f, 0.10f, 0.09f, 1f); expDen = 0.035f; - expAmb = new Color(0.025f, 0.06f, 0.06f, 1f); - break; - case ProjectM.Simulation.RoomBiomeId.Cavern: // crush-dark trench: colder + denser - expFog = new Color(0.01f, 0.05f, 0.08f, 1f); expDen = 0.05f; - expAmb = new Color(0.015f, 0.035f, 0.06f, 1f); - break; - case ProjectM.Simulation.RoomBiomeId.Blight: // gloam bloom: a sick green-violet cast - expFog = new Color(0.045f, 0.075f, 0.09f, 1f); expDen = 0.045f; - expAmb = new Color(0.045f, 0.05f, 0.075f, 1f); - break; - // default keeps the config/fallback out-shelf murk above. - } - } + // 2026-08-07 audit purge: per-room murk flavors (kelp shallows / crush-dark trench / gloam bloom) + // keyed off RunInfo.CurrentBiome. The run FSM is deleted, so the out-shelf murk config above is the + // whole look. Re-key this off LANTERN's pocket type when Phase 2 lands — the colour constants are in + // git if the three flavors are wanted back. + float x = _cam.transform.position.x; float t = Mathf.Clamp01((x - (boundary - half)) / (2f * half)); diff --git a/Assets/_Project/Scripts/Client/UI/HudTheme.cs b/Assets/_Project/Scripts/Client/UI/HudTheme.cs index cb0aa25d2..fd0977f82 100644 --- a/Assets/_Project/Scripts/Client/UI/HudTheme.cs +++ b/Assets/_Project/Scripts/Client/UI/HudTheme.cs @@ -76,25 +76,10 @@ namespace ProjectM.Client } /// Icon for a byte (null → caller falls back to the structure name text). - public Sprite StructureIcon(byte type) - { - switch (type) - { - case StructureType.Wall: return WallIcon; - case StructureType.Pylon: return PylonIcon; - default: return null; - } - } + /// Placement-ghost preview mesh for a byte (null → the cube fallback). - public Mesh StructureGhostMesh(byte type) - { - switch (type) - { - case StructureType.Wall: return WallGhostMesh; - default: return null; - } - } + // ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ---- static FontAsset _displayFa, _bodyFa, _bodyLightFa; diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index 0d3cf8013..470f5ec95 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -179,19 +179,11 @@ namespace ProjectM.Client var st = tq.GetSingleton().ServerTick; if (st.IsValid) nowTick = st.TickIndexForValidTick; } - // v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common - // exit path) would silently WIPE all meta progression on quit (the meta review's top blocker). - MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth); - SaveStructureScan.Collect(em, nowTick, out var structures); - + // 2026-08-07 audit purge: the quit-to-menu save used to collect the permanent-meta slice and the + // placed structures too. Both layers are deleted; the ledger is the whole save now. SaveService.Save(new SaveData { - RunsCompleted = runsCompleted, - MaxDepthReached = maxDepth, - MetaUpgrades = metaRows, - Ledger = rows, - Structures = structures, SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); } diff --git a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs deleted file mode 100644 index 05b02bc2f..000000000 --- a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into - /// RPCs (the MetaSpendSendSystem idiom). Carries only the class id; the server - /// re-validates the phase + applies the full swap. Statics reset on play-enter (the stale-bridge hazard). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class ClassSelectSendSystem : SystemBase - { - static readonly Queue s_Queue = new(); - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() => s_Queue.Clear(); - - /// Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this. - public static void RequestClass(byte classId) => s_Queue.Enqueue(classId); - - protected override void OnCreate() => RequireForUpdate(); - - protected override void OnUpdate() - { - while (s_Queue.Count > 0) - { - var req = EntityManager.CreateEntity(typeof(ClassSelectRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new ClassSelectRequest { ClassId = s_Queue.Dequeue() }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta deleted file mode 100644 index 167c72eb9..000000000 --- a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e0d67e293cdeb454fbef8414d4aeb813 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs deleted file mode 100644 index 836f911d6..000000000 --- a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single - /// RPC. Coalesced (one per drain — repeat E while the server is still in - /// RoomExplore is harmless, the server latches once). Statics reset on play-enter. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class PortalInteractSendSystem : SystemBase - { - static bool s_pending; - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() => s_pending = false; - - /// Request leaving via the portal (the HUD prompt / E-key near the portal drives this). - public static void Interact() => s_pending = true; - - protected override void OnCreate() => RequireForUpdate(); - - protected override void OnUpdate() - { - if (!s_pending) return; - s_pending = false; - EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest)); - } - } -} diff --git a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta deleted file mode 100644 index efc660402..000000000 --- a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: eac0f1b2340c0b344b430363311d3be5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs deleted file mode 100644 index ec1ea6c37..000000000 --- a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into - /// RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server - /// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter. - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class PrepPurchaseSendSystem : SystemBase - { - static readonly Queue s_Queue = new(); - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() => s_Queue.Clear(); - - /// Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this. - public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId); - - protected override void OnCreate() => RequireForUpdate(); - - protected override void OnUpdate() - { - while (s_Queue.Count > 0) - { - var req = EntityManager.CreateEntity(typeof(PrepPurchaseRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new PrepPurchaseRequest { OptionId = s_Queue.Dequeue() }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta deleted file mode 100644 index 4b3daac76..000000000 --- a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5b60ac8ed7ee3ce4081c55b2188be142 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs deleted file mode 100644 index b37857ecd..000000000 --- a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side ready-toggle sender: a static enqueue (HUD button at Step 14 / the T dev key / execute_code) - /// drained into RPC entities — the BuildSendSystem queue+drain idiom. The local - /// bool tracks only the toggle DIRECTION; the server-replicated is the truth the HUD - /// renders. Statics reset on play-enter (statics survive fast-enter-playmode reloads — the stale-bridge hazard). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class ReadySendSystem : SystemBase - { - static int s_Pending; // queued explicit sets - static byte s_PendingValue; - static bool s_LocalReady; // last requested state (toggle direction only, not authority) - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() - { - s_Pending = 0; - s_PendingValue = 0; - s_LocalReady = false; - } - - /// Queue an explicit ready set (HUD button / execute_code). - public static void SetReady(bool ready) - { - s_PendingValue = (byte)(ready ? 1 : 0); - s_Pending++; - s_LocalReady = ready; - } - - /// Queue a toggle of the last requested state (the T dev key; HUD replaces this at Step 14). - public static void ToggleReady() => SetReady(!s_LocalReady); - - protected override void OnCreate() - { - RequireForUpdate(); - } - - protected override void OnUpdate() - { - var keyboard = UnityEngine.InputSystem.Keyboard.current; - if (keyboard != null && keyboard.tKey.wasPressedThisFrame && !PauseMenuController.Open) - ToggleReady(); - - while (s_Pending > 0) - { - s_Pending--; - var req = EntityManager.CreateEntity(typeof(ReadyToggleRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new ReadyToggleRequest { Ready = s_PendingValue }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta deleted file mode 100644 index 47b3260f2..000000000 --- a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7047cb8f6861ba8498f33698c9948dc8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs deleted file mode 100644 index 3bcf2ae35..000000000 --- a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs +++ /dev/null @@ -1,60 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Client-side route-pick sender: a static enqueue (the Step-14 map panel's option buttons / execute_code) - /// drained into RPCs. The request is stamped from the CLIENT's replicated - /// : ForRunEpoch = (int)RunSeed (the re-meaned run-identity token — the server-only - /// RunEpoch is not client-knowable) and ForLayer = CurrentRoom VERBATIM (during a gate that is still the - /// just-cleared layer — never +1). The server re-validates everything; a stale/possessed pick is simply dropped. - /// Statics reset on play-enter (the stale-bridge hazard). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - public partial class RouteSendSystem : SystemBase - { - static int s_Pending; - static byte s_PendingIndex; - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetStatics() - { - s_Pending = 0; - s_PendingIndex = 0; - } - - /// Queue a route pick (0..RouteOptionCount-1). HUD map panel + execute_code drive this. - public static void PickRoute(byte optionIndex) - { - s_PendingIndex = optionIndex; - s_Pending++; - } - - protected override void OnCreate() - { - RequireForUpdate(); - RequireForUpdate(); - } - - protected override void OnUpdate() - { - if (s_Pending == 0) - return; - var runInfo = SystemAPI.GetSingleton(); - while (s_Pending > 0) - { - s_Pending--; - var req = EntityManager.CreateEntity(typeof(RouteSelectRequest), typeof(SendRpcCommandRequest)); - EntityManager.SetComponentData(req, new RouteSelectRequest - { - OptionIndex = s_PendingIndex, - ForRunEpoch = (int)runInfo.RunSeed, // the re-meaned replicated run token - ForLayer = runInfo.CurrentRoom, // the gate's un-incremented cleared layer - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta deleted file mode 100644 index f0b761e07..000000000 --- a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c22921cccedc3564b86d12491d88488e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs deleted file mode 100644 index dbfc3e672..000000000 --- a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs +++ /dev/null @@ -1,101 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a - /// carrier in the fresh ServerWorld BEFORE the gameplay subscene streams; this - /// system waits (RequireForUpdate) for the streamed + + - /// a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the catalog prefab at the - /// saved cell (preserving the baked Scale), restore the wounded HP born-correct, re-tag RegionTag{Base} + - /// RuntimePlacedTag, then DESTROY the carrier so it never runs again. The ledger restores separately + - /// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct BaseRestoreSystem : ISystem - { - ComponentLookup m_TransformLookup; - ComponentLookup m_HealthLookup; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - m_TransformLookup = state.GetComponentLookup(isReadOnly: true); - m_HealthLookup = state.GetComponentLookup(isReadOnly: true); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - m_TransformLookup.Update(ref state); - m_HealthLookup.Update(ref state); - - var anchor = SystemAPI.GetSingleton(); - var catalog = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (pending, carrier) in - SystemAPI.Query>().WithEntityAccess()) - { - for (int s = 0; s < pending.Length; s++) - { - var p = pending[s]; - - int entryIdx = -1; - for (int i = 0; i < catalog.Length; i++) - if (catalog[i].Type == p.Type) { entryIdx = i; break; } - if (entryIdx < 0 || catalog[entryIdx].Prefab == Entity.Null) - continue; // type not in the catalog (e.g. a save from a newer build) -> skip, don't crash - - var prefab = catalog[entryIdx].Prefab; - var structure = ecb.Instantiate(prefab); - - int2 cell = new int2(p.CellX, p.CellZ); - var xform = m_TransformLookup[prefab]; - xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale (FromPosition would reset it) - ecb.SetComponent(structure, xform); - - ecb.SetComponent(structure, new PlacedStructure - { - Type = p.Type, - Cell = cell, - NextTick = 0u, // cooldown restore retired with the automation chain (LANTERN purge) - LastProcessedTick = TickUtil.NonZero(now), - }); - // EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a - // [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the - // 0->full fallback come from the BAKED prefab, never the save. - if (m_HealthLookup.HasComponent(prefab)) - { - var hm = m_HealthLookup[prefab]; - ecb.SetComponent(structure, new Health { Current = p.HP > 0f ? p.HP : hm.Max, Max = hm.Max }); - } - ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); - ecb.AddComponent(structure); - } - - ecb.DestroyEntity(carrier); - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta deleted file mode 100644 index cfc8e2198..000000000 --- a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4003027ade5ccd5418e300d87e5c5e14 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs b/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs deleted file mode 100644 index 7339cb44f..000000000 --- a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs +++ /dev/null @@ -1,106 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative structure placement (handles RPCs). Derives - /// occupancy by scanning live ghosts into a Temp NativeHashSet (structures - /// are the source of truth — no cached buffer on the immutable BaseAnchor). For each request it validates - /// catalog/legality/occupancy/cost, and on success commits IN-PLACE (StorageMath.Withdraw on the global - /// ledger + reserve the cell in the set) so two same-tick requests for one cell can't both pass — the - /// StorageOpReceiveSystem in-place idiom — then instantiates the catalog prefab at the cell center - /// (RegionTag{Base}, world-owned, NextTick=0, LastProcessedTick stamped). Plain server SimulationSystemGroup - /// (not predicted → applied once). Rejects invalid requests silently. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct BuildPlaceSystem : ISystem - { - ComponentLookup m_TransformLookup; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - m_TransformLookup = state.GetComponentLookup(isReadOnly: true); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - m_TransformLookup.Update(ref state); - uint now = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick; - var anchor = SystemAPI.GetSingleton(); - - var catalog = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - - // Derive occupancy from the live structure set (authoritative). - var occupied = new NativeHashSet(64, Allocator.Temp); - foreach (var ps in SystemAPI.Query>()) - occupied.Add(ps.ValueRO.Cell); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (request, receive, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - var req = request.ValueRO; - int2 cell = new int2(req.CellX, req.CellZ); - - int entryIdx = -1; - for (int i = 0; i < catalog.Length; i++) - if (catalog[i].Type == req.StructureType) { entryIdx = i; break; } - - if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null - && BuildPlacementMath.CanPlace(anchor, occupied, cell)) - { - var entry = catalog[entryIdx]; - - int have = 0; - for (int i = 0; i < ledger.Length; i++) - if (ledger[i].ItemId == entry.CostResourceId) { have = ledger[i].Count; break; } - - if (have >= entry.CostAmount) - { - // Commit IN-PLACE so a second same-tick request sees the spend + reservation. - StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount); - occupied.Add(cell); - - var structure = ecb.Instantiate(entry.Prefab); - var xform = m_TransformLookup[entry.Prefab]; - xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale - ecb.SetComponent(structure, xform); - ecb.SetComponent(structure, new PlacedStructure - { - Type = req.StructureType, - Cell = cell, - NextTick = 0u, - LastProcessedTick = 0u, // 0 = uninitialized; the production systems set the baseline on first encounter (turret ignores it) - }); - ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); - ecb.AddComponent(structure); // player-built -> persisted by SaveStructureScan - } - } - - ecb.DestroyEntity(requestEntity); - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - occupied.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs.meta b/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs.meta deleted file mode 100644 index 24c101de7..000000000 --- a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d1886c7056b315e42b7754f50c43c59e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs deleted file mode 100644 index ca378633d..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs +++ /dev/null @@ -1,157 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for + the reward-grace AUTO-PICK backstop. A valid pick - /// (sender resolved, RunInfo.Lifecycle == RoomReward — the D-F4 gate — Pending == 1, index in - /// range, option id known to the catalog) appends ONE in the run-scoped BOON band - /// (Tuning.BoonSourceIdBase + BoonPickCounter++ — distinct rows, one range-strip clears the run) and - /// clears Pending; the buffer mutation is non-structural and folds through the unchanged - /// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending - /// EXPEDITION player is auto-dealt Option0 (the operator's default un-picked policy — a player always - /// gets something) so the run never stalls on an AFK picker. - /// - /// Ordering: [UpdateBefore(RunDirectorSystem)] — ALL RPC receivers sit before the director (the - /// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the - /// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward), - /// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads - /// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct BoonApplySystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var dirEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(dirEntity); - var run = SystemAPI.GetComponent(dirEntity); - bool rewarding = info.Lifecycle == RunLifecycle.RoomReward; - - var catalog = SystemAPI.GetComponent(SystemAPI.GetSingletonEntity()); - if (!catalog.Value.IsCreated) - return; - ref var pool = ref catalog.Value.Value; - - bool runDirty = false; - - // ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ---- - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = entity; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, req, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - if (rewarding - && req.ValueRO.Index < 3 - && SystemAPI.HasComponent(conn) - && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) - { - var offer = SystemAPI.GetComponent(player); - if (offer.Pending == 1) - { - byte id = req.ValueRO.Index == 2 ? offer.Option2 - : req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0; - if (Apply(ref state, player, id, ref pool, ref run)) - { - offer.Pending = 0; - SystemAPI.SetComponent(player, offer); - runDirty = true; - } - } - } - ecb.DestroyEntity(requestEntity); - } - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - - // ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ---- - if (rewarding && run.RewardGraceTick != 0u) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick)) - { - foreach (var (offer, region, entity) in - SystemAPI.Query, RefRO>() - .WithAll().WithEntityAccess()) - { - if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition) - continue; - if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run)) - runDirty = true; - offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate - } - } - } - - if (runDirty) - SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance) - } - - /// Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero. - static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run) - { - if (boonId == 0) - return false; - int idx = BoonMath.FindDef(ref pool, boonId); - if (idx < 0) - 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(player)) - return false; // real players are baked with it; skip defensively otherwise - var fx = state.EntityManager.GetComponentData(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(player); - mods.Add(new StatModifier - { - Target = pool.Defs[idx].Target, - Op = pool.Defs[idx].Op, - Value = pool.Defs[idx].Value, - SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan), - }); - run.BoonPickCounter += 1; - return true; - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta deleted file mode 100644 index 80a1a84dc..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5749745bedc86ca4396b9a3911ef8773 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs deleted file mode 100644 index 699c7426b..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-only choice-of-3 boon dealer: once per (int-equality latch on - /// , attached beside the catalog singleton), when the run FSM enters RoomReward it - /// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via - /// — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes - /// the player's owner-only replicated (Pending=1). A base-region player (dead-respawned, - /// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem - /// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers. - /// - /// Ordering: [UpdateAfter(RunDirectorSystem)] — on the RoomReward ENTRY tick this runs after the - /// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(RunDirectorSystem))] - public partial struct BoonOfferSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var catalogEntity = SystemAPI.GetSingletonEntity(); - - // One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom). - if (!SystemAPI.HasComponent(catalogEntity)) - { - state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState()); - return; // structural change — clean re-read next tick - } - - var dirEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(dirEntity); - if (info.Lifecycle != RunLifecycle.RoomReward) - return; - - var run = SystemAPI.GetComponent(dirEntity); - var offered = SystemAPI.GetComponent(catalogEntity); - if (offered.OfferedRoomEpoch == run.RoomEpoch) - return; // this room's offers are already dealt - - var catalog = SystemAPI.GetComponent(catalogEntity); - if (!catalog.Value.IsCreated) - return; - ref var pool = ref catalog.Value.Value; - - foreach (var (offer, owner, region, cls, fx) in - SystemAPI.Query, RefRO, RefRO, RefRO, RefRO>() - .WithAll()) - { - if (region.ValueRO.Region != RegionId.Expedition) - continue; // home-bound players (dead-respawned, joiners) are dealt nothing - - // 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; - 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 }; - } - - offered.OfferedRoomEpoch = run.RoomEpoch; - SystemAPI.SetComponent(catalogEntity, offered); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta deleted file mode 100644 index aa8806809..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2d3715c60d2cc2348ac4ff7600006d23 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs deleted file mode 100644 index 4edf964e6..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs +++ /dev/null @@ -1,261 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Physics; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of .WithAll<EnemyTag, BossState>() - /// (EnemyAISystem's Charger MOVE pass excludes it via .WithNone<BossState>(), so exactly one system - /// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain - /// [UpdateAfter(EnemyAISystem)] (a linear chain, no sort cycle), once per - /// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick). - /// - /// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial - /// SLAM — the client danger cue rides the replicated [GhostField] (CombatFeedbackSystem - /// draws a boss-scale ring). At/below HP it enters phase two: faster, - /// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed - /// gap-closer on its own cooldown when the target sits outside slam reach; LungeState.UntilTick spans the - /// windup+travel so EnemyAISystem's IsLunging derive replicates the tell (the client suppresses the slam ring - /// off that bit), and BossState.PendingAttack (server-only byte) tells the shared windup-elapse branch WHICH - /// attack fires. Knockback-immune (the stamp - /// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go - /// through so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the - /// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route - /// through TickUtil.NonZero and compare with only (never raw uint). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(EnemyAISystem))] - public partial struct BossAISystem : ISystem - { - EntityQuery m_Bosses; - EntityQuery m_ZoneEnemies; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.Exclude()); - state.RequireForUpdate(m_Bosses); - m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // summon cap counts LIVING only (B3) - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - float dt = SystemAPI.Time.DeltaTime; - - // Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order). - var playerEntities = new NativeList(Allocator.Temp); - var playerPositions = new NativeList(Allocator.Temp); - foreach (var (xform, health, region, entity) in - SystemAPI.Query, RefRO, RefRO>() - .WithAll().WithEntityAccess()) - { - if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition) - continue; - playerEntities.Add(entity); - playerPositions.Add(xform.ValueRO.Position); - } - - // Collide-and-slide setup (mirrors EnemyAISystem). - bool havePhysics = SystemAPI.TryGetSingleton(out var physics); - uint envMask = SystemAPI.TryGetSingleton(out var worldCol) ? worldCol.EnvironmentMask : 0u; - uint sweepMask = envMask | worldCol.StructureMask; - var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 }; - bool sweep = havePhysics && sweepMask != 0u; - const float SweepRadius = 0.8f; // the boss is a big body - - int liveZone = m_ZoneEnemies.CalculateEntityCount(); - - // Summon resources (phase two): the swarmer prefab + baked transform + the current room byte. - bool haveDirector = SystemAPI.TryGetSingletonEntity(out var directorEntity); - Entity swarmerPrefab = Entity.Null; - LocalTransform swarmerBaked = default; - if (haveDirector) - { - var prefabs = SystemAPI.GetBuffer(directorEntity); - if (prefabs.Length > ZoneEnemyMath.KindSwarmer) - { - swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab; - if (swarmerPrefab != Entity.Null) - swarmerBaked = state.EntityManager.GetComponentData(swarmerPrefab); - } - } - byte roomByte = SystemAPI.TryGetSingleton(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (xform, stats, health, boss, windup, knockback, lunge) in - SystemAPI.Query, RefRO, RefRO, RefRW, - RefRW, RefRW, RefRW>() - .WithAll().WithNone()) - { - float3 pos = xform.ValueRO.Position; - - // Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss. - if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u; - - // Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max. - float maxHp = math.max(1f, health.ValueRO.Max); - byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1; - boss.ValueRW.Phase = phase; - - // Target: nearest living expedition player. - int tgt = -1; float bestSq = float.MaxValue; - for (int i = 0; i < playerPositions.Length; i++) - { - float d = math.distancesq(pos, playerPositions[i]); - if (d < bestSq) { bestSq = d; tgt = i; } - } - if (tgt < 0) - continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition) - float3 targetPos = playerPositions[tgt]; - - // Face the target (planar) at all times, incl. while telegraphing. - float3 toTarget = targetPos - pos; toTarget.y = 0f; - if (math.lengthsq(toTarget) > 1e-6f) - xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up()); - - // --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. --- - uint windRaw = windup.ValueRO.WindUpUntilTick; - if (windRaw != 0u) - { - var wt = new NetworkTick(windRaw); - if (!(wt.IsValid && wt.IsNewerThan(serverTick))) - { - // B4: the windup elapse fires whichever attack was PENDING - the shared AttackWindup field - // alone cannot tell them apart (review-confirmed: the naive reuse slams on a lunge elapse). - if (boss.ValueRO.PendingAttack == 1) - { - // Lunge commit: lock direction at travel start (the Charger contract - dodge DURING - // travel with dash i-frames). No unique damage: arriving re-opens the slam threat. - lunge.ValueRW.Dir = math.normalizesafe(toTarget.xz, new float2(0f, 1f)); - lunge.ValueRW.Speed = Tuning.BossLungeSpeed; - lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeDurationTicks); - windup.ValueRW.WindUpUntilTick = 0u; - continue; - } - float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius; - for (int i = 0; i < playerEntities.Length; i++) - { - if (math.distancesq(pos, playerPositions[i]) > slamSq) - continue; - ecb.AppendToBuffer(playerEntities[i], new DamageEvent - { - Amount = Tuning.BossSlamDamage, - SourceNetworkId = -1, // environment / boss, not a player - SourceTick = TickUtil.NonZero(now), - }); - } - windup.ValueRW.WindUpUntilTick = 0u; - uint baseCd = Tuning.BossSlamCooldownTicks; - uint cd = phase == 2 - ? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult) - : baseCd; - boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd); - } - continue; // rooted while winding up (the tell); rotation already written above - } - - // --- B4 LUNGE travel in progress: committed movement along the locked direction. Wall-stop or - // timer ends it (the Charger contract); the replicated IsLunging bit rides LungeState.UntilTick. --- - if (lunge.ValueRO.UntilTick != 0u) - { - var blt = new NetworkTick(lunge.ValueRO.UntilTick); - if (blt.IsValid && blt.IsNewerThan(serverTick)) - { - float3 intended = pos + new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y) * (lunge.ValueRO.Speed * dt); - intended.y = pos.y; - float3 moved = sweep ? EnemyMoveUtil.SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended; - xform.ValueRW.Position = moved; - if (math.lengthsq(lunge.ValueRO.Dir) > 1e-6f) - xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y), math.up()); - float intendedDist = math.distance(pos.xz, intended.xz); - float actualDist = math.distance(pos.xz, moved.xz); - if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f) - { - lunge.ValueRW.UntilTick = 0u; // wall-stop -> end the travel early - boss.ValueRW.PendingAttack = 0; - boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks); - } - continue; // committed this tick - } - lunge.ValueRW.UntilTick = 0u; // travel done - boss.ValueRW.PendingAttack = 0; - boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks); - } - - // --- Chase (no active slam). --- - float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f); - float stopDist = stats.ValueRO.AttackRange * 0.9f; - float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist); - float3 newPos = pos + vel * dt; newPos.y = pos.y; - if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter); - xform.ValueRW.Position = newPos; - - // Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam. - bool slamReady = boss.ValueRO.SlamReadyTick == 0u - || !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick); - float lead = Tuning.BossSlamRadius + 1.5f; - float tgtDistSq = math.distancesq(newPos, targetPos); - if (slamReady && tgtDistSq <= lead * lead) - { - windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks); - boss.ValueRW.PendingAttack = 0; - } - else - { - // B4 lunge gate: target out of slam reach but within lunge range -> telegraphed gap-closer. - // LungeState.UntilTick spans windup+travel so the IsLunging ghost bit (derived by EnemyAISystem - // from LungeState) is ON for the whole move - the client suppresses the slam ring off that bit. - bool lungeReady = boss.ValueRO.LungeReadyTick == 0u - || !new NetworkTick(boss.ValueRO.LungeReadyTick).IsNewerThan(serverTick); - if (lungeReady - && tgtDistSq >= Tuning.BossLungeMinRange * Tuning.BossLungeMinRange - && tgtDistSq <= Tuning.BossLungeMaxRange * Tuning.BossLungeMaxRange) - { - windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks); - boss.ValueRW.PendingAttack = 1; - lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks + Tuning.BossLungeDurationTicks); - } - } - - // Summon (phase two only): ready + under the live cap + a swarmer prefab wired. - if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap) - { - bool summonReady = boss.ValueRO.SummonReadyTick == 0u - || !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick); - if (summonReady) - { - int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone); - for (int k = 0; k < toSpawn; k++) - { - float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f); - spawnPos.y = newPos.y; - ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte); - liveZone++; - } - boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks); - } - } - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - playerEntities.Dispose(); - playerPositions.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta deleted file mode 100644 index c8e6dcc42..000000000 --- a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 105d73021b780c449a16ea72bcc29b69 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs index 4d979f870..d83f153a0 100644 --- a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs @@ -9,36 +9,33 @@ namespace ProjectM.Server /// Server receiver for — the player picks their frame at base. Honored ONLY in /// Staging (frame = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the /// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via (class seeds + - /// permanent-meta re-sync), writes FrameId / PlayerClass, re-seeds the 4-socket Spark loadout, and calls + /// permanent-meta re-sync), writes FrameId, re-seeds the 4-socket Spark loadout, and calls /// . Plain server group, before RunDirectorSystem (the receiver convention); /// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC). /// - /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] public partial struct ClassSelectReceiveSystem : ISystem { public void OnCreate(ref SystemState state) { var b = new EntityQueryBuilder(Allocator.Temp).WithAll(); state.RequireForUpdate(state.GetEntityQuery(b)); - state.RequireForUpdate(); } public void OnUpdate(ref SystemState state) { - bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; + // 2026-08-07 audit purge: this used to accept a frame swap only during RunInfo Lifecycle==Staging + // (the base-phase gate). With the run FSM gone the gym accepts a swap at any time. + const bool accept = true; var playerByConn = new NativeHashMap(8, Allocator.Temp); foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess()) playerByConn[owner.ValueRO.NetworkId] = e; - // Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded). - Entity dir = Entity.Null; - bool haveMeta = SystemAPI.TryGetSingleton(out var metaCat) - && SystemAPI.TryGetSingletonEntity(out dir) && SystemAPI.HasBuffer(dir); + // 2026-08-07 audit purge: the permanent-meta re-sync (MetaUpgradeCatalog + MetaTierState) went + // with the meta shop; a frame swap now re-seeds only the frame stat band. bool haveDb = SystemAPI.TryGetSingleton(out var abilityDb); var ecb = new EntityCommandBuffer(Allocator.Temp); @@ -54,13 +51,12 @@ namespace ProjectM.Server if (!SystemAPI.HasBuffer(player)) continue; var mods = SystemAPI.GetBuffer(player); - var metaRecord = haveMeta ? SystemAPI.GetBuffer(dir) : default; - ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, out byte newClass); + ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, out byte newClass); if (SystemAPI.HasComponent(player)) SystemAPI.SetComponent(player, new FrameId { Value = newClass }); - if (SystemAPI.HasComponent(player)) - SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass }); + + // Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (fires now). ClassTraits.FrameLoadout(newClass, out byte f0, out byte f1, out byte f2, out byte f3); var sockets = SystemAPI.GetBuffer(player); diff --git a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs deleted file mode 100644 index 2fe0f39ed..000000000 --- a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs +++ /dev/null @@ -1,139 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Phase 1.7 "Blade Dash" boon (): while a player is inside its dash blink window, - /// living enemies within 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 (dash state committed) and before - /// HealthApplyDamageSystem (the DamageEvent drains the same tick). Enemies carry no DashState, so the - /// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless. - /// - /// Dedup is keyed to (which is TickUtil.NonZero(now) on every dash and has - /// NO reliable clear edge on a release server): is cleared whenever the current - /// StartTick differs from . 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 (<~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). - /// - [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(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var nt = SystemAPI.GetSingleton(); - var serverTick = nt.ServerTick; - if (!serverTick.IsValid) - return; - - // Snapshot living enemies once (positions + radii + entities), stable query order. - var enemyEntities = new NativeList(Allocator.Temp); - var enemyPositions = new NativeList(Allocator.Temp); - var enemyRadii = new NativeList(Allocator.Temp); - foreach (var (tx, hr, hp, te) in - SystemAPI.Query, RefRO, RefRO>() - .WithAll().WithNone().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, RefRW, - RefRO, RefRO>() - .WithAll()) - { - 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; - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta deleted file mode 100644 index 2e4ca081a..000000000 --- a/Assets/_Project/Scripts/Server/Combat/DashTrailDamageSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f2a00802a81103745a1d20475a3c7b7b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 2c0775d79..f956e1c26 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -27,14 +27,13 @@ namespace ProjectM.Server [UpdateAfter(typeof(PredictedSimulationSystemGroup))] public partial struct EnemyAISystem : ISystem { - EntityQuery m_EnemyProjectiles; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); - m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly()); + } [BurstCompile] @@ -62,9 +61,12 @@ namespace ProjectM.Server var structureEntities = new NativeList(Allocator.Temp); var structurePositions = new NativeList(Allocator.Temp); var structureRegions = new NativeList(Allocator.Temp); + // Structures were deleted with the shell (2026-08-07 audit purge), so the raze-target snapshot is + // empty. The lists stay so the aggro selection below keeps one code path; drop them when the + // LANTERN buildables land and give enemies something to attack again. foreach (var (sx, sh, sr, se) in SystemAPI.Query, RefRO, RefRO>() - .WithAll() + .WithAll() .WithEntityAccess()) { if (sh.ValueRO.Current <= 0f) @@ -120,14 +122,14 @@ namespace ProjectM.Server if (sweep) { foreach (var depenXform in SystemAPI.Query>() - .WithAll().WithNone().WithNone()) + .WithAll().WithNone().WithNone()) depenXform.ValueRW.Position = EnemyMoveUtil.Depenetrate(in physics, depenXform.ValueRO.Position, SweepRadius, envFilter); } foreach (var (xform, stats, cooldown, knockback, windup, region) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone().WithNone()) + .WithAll().WithNone().WithNone()) { float3 pos = xform.ValueRO.Position; byte huskRegion = region.ValueRO.Region; @@ -246,267 +248,13 @@ namespace ProjectM.Server } } - // --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge. - // Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone(). - // Charger feel knobs — live-tunable via TuningConfig (MC-0), guarded at the read site. Server-only - // (clients never simulate Chargers); the >=1-tick floor avoids a degenerate instant/no-travel lunge. - float ChargerLungeSpeed = math.max(0f, tune.ChargerLungeSpeed); // units/s while lunging - uint ChargerLungeDurationTicks = (uint)math.max(1f, tune.ChargerLungeDurationTicks); // committed travel - uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead - uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window - uint chargerWhiffsThisTick = 0; - foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in - SystemAPI.Query, RefRO, RefRW, - RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) - { - float3 pos = xform.ValueRO.Position; - byte cHuskRegion = region.ValueRO.Region; + // --- Charger / Spitter / IsLunging passes DELETED 2026-08-07 (audit purge). + // ChargerAuthoring, SpitterAuthoring and SwarmerAuthoring were attached to ZERO prefabs, so + // LungeState / SpitterState / SwarmerTag were never baked and these three passes could not match a + // single chunk at runtime — ~272 lines of Bursted code plus 734 lines of green tests certifying an + // escalation curve that always resolved to Grunt. Recover from git if the lunge/spit behaviours are + // wanted; the LANTERN bestiary reintroduces variety through the CreatureKit path instead. - // 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer). - var kb = knockback.ValueRO; - if (kb.UntilTick != 0) - { - var kbTick = new NetworkTick(kb.UntilTick); - if (kbTick.IsValid && kbTick.IsNewerThan(serverTick)) - { - float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt); - kpos.y = pos.y; - if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter); - xform.ValueRW.Position = kpos; - if (kb.Speed >= tune.StaggerKnockbackSpeed) - { - windup.ValueRW.WindUpUntilTick = 0; // B2 poise: only a HEAVY hit breaks the windup / committed lunge - lunge.ValueRW.UntilTick = 0; - } - continue; - } - knockback.ValueRW.UntilTick = 0; - } - - // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper). - EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx); - if (cIdx < 0) - continue; - Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]; - float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]; - - // 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff. - var lg = lunge.ValueRO; - if (lg.UntilTick != 0) - { - var lgTick = new NetworkTick(lg.UntilTick); - if (lgTick.IsValid && lgTick.IsNewerThan(serverTick)) - { - float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt); - intended.y = pos.y; - float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended; - xform.ValueRW.Position = moved; - if (math.lengthsq(lg.Dir) > 1e-6f) - xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up()); - - if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange)) - { - if (cTargetEntity != Entity.Null) ecb.AppendToBuffer(cTargetEntity, new DamageEvent - { - Amount = stats.ValueRO.AttackDamage, - SourceNetworkId = -1, - SourceTick = TickUtil.NonZero(now), - }); - uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks); - cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks); - lunge.ValueRW.UntilTick = 0; // landed -> end the lunge - } - else - { - float intendedDist = math.distance(pos.xz, intended.xz); - float actualDist = math.distance(pos.xz, moved.xz); - if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f) - { - cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); - lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window) - chargerWhiffsThisTick++; - lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window - } - } - continue; // committed this tick - } - - // Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick. - cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); - lunge.ValueRW.UntilTick = 0; - chargerWhiffsThisTick++; - lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window - } - - // 3. Seek + face (shared shape with the Grunt path). B3: a whiffed Charger is ROOTED during its - // stagger punish window so the advertised punish reads (the player sees it stop). Facing still tracks. - bool cStaggered = lunge.ValueRO.StaggerUntilTick != 0u - && new NetworkTick(lunge.ValueRO.StaggerUntilTick).IsNewerThan(serverTick); - if (!cStaggered) - { - float cStop = stats.ValueRO.AttackRange * 0.9f; - float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop); - float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y; - if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter); - xform.ValueRW.Position = cNewPos; - } - float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f; - if (math.lengthsq(cToTarget) > 1e-6f) - xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up()); - - // 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the - // whole point is the commit lands even if the player dodged out of range (the punishable tell). - uint cWindRaw = windup.ValueRO.WindUpUntilTick; - if (cWindRaw != 0) - { - var cWindTick = new NetworkTick(cWindRaw); - if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick))) - { - float3 toT = cTargetPos - pos; toT.y = 0f; - float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f); - lunge.ValueRW.Dir = ldir; - lunge.ValueRW.Speed = ChargerLungeSpeed; - lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks); - windup.ValueRW.WindUpUntilTick = 0; - } - } - else - { - bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange); - if (cInRange) - { - bool cReady = cooldown.ValueRO.NextAttackTick == 0 - || !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick); - if (cReady) - windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks); - } - } - } - // --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a - // telegraphed, dodgeable spit. Partitioned .WithAll().WithNone() (and the Grunt - // pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule. - bool haveSpit = SystemAPI.TryGetSingleton(out var spitCfg) && spitCfg.Prefab != Entity.Null; - int liveSpits = m_EnemyProjectiles.CalculateEntityCount(); - LocalTransform spitBakedLt = default; - EnemyProjectile spitBakedProj = default; - if (haveSpit) - { - spitBakedLt = state.EntityManager.GetComponentData(spitCfg.Prefab); - spitBakedProj = state.EntityManager.GetComponentData(spitCfg.Prefab); - } - foreach (var (xform, stats, knockback, windup, spitter, region) in - SystemAPI.Query, RefRO, RefRW, - RefRW, RefRW, RefRO>() - .WithAll().WithNone().WithNone()) - { - float3 pos = xform.ValueRO.Position; - byte sRegion = region.ValueRO.Region; - - // 1. Knockback overrides everything (sole Position writer preserved). - var kb = knockback.ValueRO; - if (kb.UntilTick != 0) - { - var kbTick = new NetworkTick(kb.UntilTick); - if (kbTick.IsValid && kbTick.IsNewerThan(serverTick)) - { - float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt); - kpos.y = pos.y; - if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter); - xform.ValueRW.Position = kpos; - if (kb.Speed >= tune.StaggerKnockbackSpeed) - windup.ValueRW.WindUpUntilTick = 0; // B2 poise: light hits nudge, only heavy interrupts - continue; - } - knockback.ValueRW.UntilTick = 0; - } - - // 2. Target (region-scoped shared helper); no target -> idle. - EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx); - if (sIdx < 0) - continue; - Entity sTargetEntity = sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]; - float3 sTargetPos = sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]; - - // 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target. - var sp = spitter.ValueRO; - // Once the player has closed inside CorneredRange the Spitter STANDS (no flee) + point-blanks — so a - // melee player who commits can actually catch it (fixes the endless-kite complaint; the spit is dash-dodgeable). - bool sCorneredMove = math.distance(pos.xz, sTargetPos.xz) <= sp.CorneredRange; - float3 bandVel = sCorneredMove ? float3.zero - : EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance); - float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y; - if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter); - xform.ValueRW.Position = sNewPos; - float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f; - if (math.lengthsq(sToTarget) > 1e-6f) - xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up()); - - // 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse, - // spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner. - uint sWindRaw = windup.ValueRO.WindUpUntilTick; - if (sWindRaw != 0) - { - var sWindTick = new NetworkTick(sWindRaw); - if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick))) - { - float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f); - if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles)) - { - float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f; - spawnPos.y = pos.y; - var spit = ecb.Instantiate(spitCfg.Prefab); - ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale - ecb.SetComponent(spit, new EnemyProjectile - { - Direction = dir2, - Speed = sp.ProjectileSpeed, - Damage = stats.ValueRO.AttackDamage, - Range = spitBakedProj.Range, - DistanceTravelled = 0f, - LastStep = 0f, - Region = sRegion, - }); - ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none) - liveSpits++; - uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks); - spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd); - } - else - { - // Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn. - spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u); - } - windup.ValueRW.WindUpUntilTick = 0; - } - } - else - { - bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick); - // In-band gate (DR-041): telegraph + fire ONLY when holding the preferred band, OR when the target has - // closed inside CorneredRange (point-blank, no retreat room). While ADVANCING from too far OR - // RETREATING from a too-close target it must NOT fire — that IS the hold-range "reposition" question. - float sDist = math.length(sToTarget); - bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance; - bool sCornered = sDist <= sp.CorneredRange; - if (sReady && (sInBand || sCornered)) - { - uint wTicks = (uint)math.max(1, sp.WindupTicks); - windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks); - } - } - } - - - // Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState - // (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a - // Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables). - foreach (var (lunge, isLunging) in - SystemAPI.Query, EnabledRefRW>() - .WithAll().WithPresent().WithNone().WithNone()) - { - isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick - } // --- Phase 1 B1: SEPARATION (soft-collision) so hordes stop interpenetrating. Lives INSIDE // EnemyAISystem (the sole enemy-Position writer; BossAISystem runs after and re-owns the boss). @@ -523,17 +271,12 @@ namespace ProjectM.Server foreach (var (sxf, shr, se) in SystemAPI.Query, RefRO>() .WithAll().WithNone().WithEntityAccess()) { - bool movable = !SystemAPI.HasComponent(se); + bool movable = true; if (movable && SystemAPI.HasComponent(se)) { var k = SystemAPI.GetComponent(se); if (k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick)) movable = false; } - if (movable && SystemAPI.HasComponent(se)) - { - var l = SystemAPI.GetComponent(se); - if (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick)) movable = false; - } sepEnt.Add(se); sepPos.Add(sxf.ValueRO.Position); sepRad.Add(shr.ValueRO.Value); sepMov.Add(movable); } float sepMaxStep = math.max(0f, tune.SeparationMaxSpeed) * dt; @@ -591,7 +334,7 @@ namespace ProjectM.Server float nudgeStep = UnstickNudgeSpeed * dt; foreach (var (nxform, nstats, nav, nregion, nent) in SystemAPI.Query, RefRO, RefRW, RefRO>() - .WithAll().WithNone().WithNone().WithEntityAccess()) + .WithAll().WithNone().WithNone().WithEntityAccess()) { float3 npos = nxform.ValueRO.Position; byte nRegion = nregion.ValueRO.Region; @@ -602,12 +345,6 @@ namespace ProjectM.Server var k = SystemAPI.GetComponent(nent); committed |= k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick); } - if (SystemAPI.HasComponent(nent)) - { - var l = SystemAPI.GetComponent(nent); - committed |= (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick)) - || (l.StaggerUntilTick != 0 && new NetworkTick(l.StaggerUntilTick).IsNewerThan(serverTick)); - } EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx); bool hasTarget = nIdx >= 0; @@ -669,8 +406,6 @@ namespace ProjectM.Server } } - if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton()) - SystemAPI.GetSingletonRW().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick; ecb.Playback(state.EntityManager); diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs deleted file mode 100644 index 131fbca5f..000000000 --- a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs +++ /dev/null @@ -1,137 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only - /// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the - /// plain after (post-move position). - /// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED - /// (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a - /// target whose .Region != the spit's Region is skipped — relevancy hides cross-region - /// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs - /// its own guard (the missing-filter blocker the design review caught). On a hit it appends - /// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted - /// HealthApplyDamageSystem — appending from the predicted loop would double-apply on rollback; SourceTick - /// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and - /// destroys the spit at-most-once; a spit past its Range expires. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(EnemyProjectileMoveSystem))] - public partial struct EnemyProjectileDamageSystem : ISystem - { - /// Extra forgiveness for the spit's own size, added to a target's hit radius. - const float k_ProjectileRadius = 0.2f; - - /// Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them). - const float k_StructureRadius = 1.0f; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) return; // mirror WaveSystem/ZoneEnemyDirectorSystem — never stamp SourceTick off an invalid tick - uint now = serverTick.TickIndexForValidTick; - var ecb = new EntityCommandBuffer(Allocator.Temp); - - // Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring); - // STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant. - var targetEntities = new NativeList(Allocator.Temp); - var targetPositions = new NativeList(Allocator.Temp); - var targetRadii = new NativeList(Allocator.Temp); - var targetRegions = new NativeList(Allocator.Temp); - - foreach (var (xform, hitRadius, health, region, e) in - SystemAPI.Query, RefRO, RefRO, RefRO>() - .WithAll().WithEntityAccess()) - { - if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse - targetEntities.Add(e); - targetPositions.Add(xform.ValueRO.Position); - targetRadii.Add(hitRadius.ValueRO.Value); - targetRegions.Add(region.ValueRO.Region); - } - foreach (var (xform, health, region, e) in - SystemAPI.Query, RefRO, RefRO>() - .WithAll().WithEntityAccess()) - { - if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick - targetEntities.Add(e); - targetPositions.Add(xform.ValueRO.Position); - targetRadii.Add(k_StructureRadius); - targetRegions.Add(region.ValueRO.Region); - } - - var destroyed = new NativeHashSet(16, Allocator.Temp); - foreach (var (xform, proj, projEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - float3 cur = xform.ValueRO.Position; - float2 segEnd = new float2(cur.x, cur.z); - float2 dir = proj.ValueRO.Direction; - float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt - float2 seg = segEnd - segStart; - float segLenSq = math.lengthsq(seg); - byte projRegion = proj.ValueRO.Region; - - int bestIdx = -1; - float bestT = float.MaxValue; - for (int i = 0; i < targetEntities.Length; i++) - { - if (targetRegions[i] != projRegion) continue; // server-side damage region guard - float2 tp = new float2(targetPositions[i].x, targetPositions[i].z); - float t = segLenSq > 1e-8f - ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) - : 0f; - float2 closest = segStart + t * seg; - float hitDist = targetRadii[i] + k_ProjectileRadius; - if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT) - { - bestT = t; - bestIdx = i; - } - } - - if (bestIdx >= 0) - { - ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent - { - Amount = proj.ValueRO.Damage, - SourceNetworkId = -1, // hostile environment, not a player - SourceTick = TickUtil.NonZero(now), - }); - if (destroyed.Add(projEntity)) - ecb.DestroyEntity(projEntity); - continue; - } - - if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity)) - ecb.DestroyEntity(projEntity); - } - - ecb.Playback(state.EntityManager); - - ecb.Dispose(); - destroyed.Dispose(); - targetEntities.Dispose(); - targetPositions.Dispose(); - targetRadii.Dispose(); - targetRegions.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta deleted file mode 100644 index 4a4f65cbf..000000000 --- a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs deleted file mode 100644 index 22c0a6e1d..000000000 --- a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs +++ /dev/null @@ -1,49 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// MC-2 — integrates hostile Spitter projectiles () server-only in the plain - /// (the spits are ownerless INTERPOLATED ghosts, not predicted — like the - /// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates - /// DistanceTravelled, and STORES = Speed*dt so - /// can rebuild the exact swept segment it traversed this tick - /// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling - /// discipline — a fresh delta in the damage pass is the trap). Ordered [UpdateAfter(EnemyAISystem)] (the - /// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant); - /// structural-free. dt is the server fixed step here, exactly as reads it. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(EnemyAISystem))] - public partial struct EnemyProjectileMoveSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem - foreach (var (xform, proj) in SystemAPI.Query, RefRW>()) - { - float step = proj.ValueRO.Speed * dt; - float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y); - float3 from = xform.ValueRO.Position; - float3 pos = from + dir * step; - pos.y = from.y; // hold the movement plane - xform.ValueRW.Position = pos; - proj.ValueRW.LastStep = step; - proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step; - } - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta deleted file mode 100644 index 35e070909..000000000 --- a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9acb4c22874b1fa489433644b90334db \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs index 9ac5818ff..6cc67c37f 100644 --- a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs @@ -74,7 +74,6 @@ namespace ProjectM.Server bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); DashState ds = hasDash ? SystemAPI.GetComponent(entity) : default; - bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); uint negatedForThisEntity = 0u; float total = 0f; int killerNetId = -1; // Phase 1.7: last player-sourced (non-negated) hit this tick → on-kill boon credit @@ -99,22 +98,9 @@ namespace ProjectM.Server 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 - // whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1. - if (isCharger && dmg[i].SourceNetworkId >= 0) - { - var lunge = SystemAPI.GetComponent(entity); - if (lunge.StaggerUntilTick != 0u) - { - var stag = new NetworkTick(lunge.StaggerUntilTick); - if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick)) - { - punishesThisTick++; - lunge.StaggerUntilTick = 0u; - SystemAPI.SetComponent(entity, lunge); - } - } - } + // 2026-08-07 audit purge: the Charger whiff-punish scoring lived here (a player hit landing + // inside LungeState.StaggerUntilTick scored a punish). LungeState was never baked onto any + // prefab, so this branch was unreachable; it went with the Charger. } dmg.Clear(); if (negatedForThisEntity != 0u) @@ -156,8 +142,6 @@ namespace ProjectM.Server }); if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(AttackWindup)); if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(KnockbackState)); - if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(LungeState)); - if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponentEnabled(entity, false); } } else if (SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity)) diff --git a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs deleted file mode 100644 index 77ec468b7..000000000 --- a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs +++ /dev/null @@ -1,104 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Phase 1.7 on-kill boons. When HealthApplyDamageSystem stamps an enemy it records the - /// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse: - /// heals the killer (clamped to ) and - /// refreshes a short cooldown-reduction buff ( — - /// re-stamped, never stacked). Idempotent via the latch (a value write, no edge-detect). - /// - /// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW - /// access, which would alias that system's RefRW<Health> victim query. Here the - /// only query is RefRW<Dying> 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. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] - [UpdateAfter(typeof(HealthApplyDamageSystem))] - public partial struct KillRewardSystem : ISystem - { - ComponentLookup m_Fx; - ComponentLookup m_Health; - ComponentLookup m_EffChar; - BufferLookup m_Mods; - BufferLookup m_Timed; - - const float k_SiphonHeal = 8f; // HP restored per kill (tunable) - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - m_Fx = state.GetComponentLookup(isReadOnly: true); - m_Health = state.GetComponentLookup(isReadOnly: false); - m_EffChar = state.GetComponentLookup(isReadOnly: true); - m_Mods = state.GetBufferLookup(isReadOnly: false); - m_Timed = state.GetBufferLookup(isReadOnly: false); - state.RequireForUpdate(); - state.RequireForUpdate(); // only run while a fresh corpse exists - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().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(8, Allocator.Temp); - foreach (var (owner, e) in SystemAPI.Query>().WithAll().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>().WithAll().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(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta deleted file mode 100644 index 0f57676e3..000000000 --- a/Assets/_Project/Scripts/Server/Combat/KillRewardSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 43348399863cc454a8752cce54cc329d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs deleted file mode 100644 index c7f3d706f..000000000 --- a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for — the base PREP-LOADOUT spend (DR-046). Modeled on - /// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity - /// idiom — pre-check BEFORE , since Withdraw - /// CLAMPS and never rejects). A purchase appends ONE run-scoped in the prep band - /// ( + option id) on the BUYER only (prep is personal). "Once per run" needs - /// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so - /// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem; - /// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct PrepPurchaseSystem : ISystem - { - public void OnCreate(ref SystemState state) - { - var b = new EntityQueryBuilder(Allocator.Temp).WithAll(); - state.RequireForUpdate(state.GetEntityQuery(b)); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - public void OnUpdate(ref SystemState state) - { - bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; - var director = SystemAPI.GetSingletonEntity(); - - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, e) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = e; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, req, reqEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - ecb.DestroyEntity(reqEntity); // ALWAYS consumed - if (!accept) continue; - - var conn = receive.ValueRO.SourceConnection; - if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var buyer)) - continue; - if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop - - uint sourceId = Tuning.PrepSourceIdBase + row.Id; - var mods = SystemAPI.GetBuffer(buyer); - bool already = false; - for (int m = 0; m < mods.Length; m++) - if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning) - if (already) continue; - - // LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass). - var ledger = SystemAPI.GetBuffer(director); - if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS - StorageMath.Withdraw(ledger, row.CostResId, row.Cost); - - mods.Add(new StatModifier - { - Target = row.Target, - Op = row.Op, - Value = row.Value, - SourceId = sourceId, - }); - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - playerByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta deleted file mode 100644 index 8f199d737..000000000 --- a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f052eb701594a3a42ba83e524dd2d28b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index 12d0bd879..5f779b1ef 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -52,9 +52,8 @@ namespace ProjectM.Server /// RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel. ComponentLookup m_ReelLookup; - /// Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is + /// Knockback stamp lookup. The former BossState immunity gate went with the boss purge /// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups. - ComponentLookup m_BossLookup; /// RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set. ComponentLookup m_FxLookup; @@ -77,7 +76,7 @@ namespace ProjectM.Server m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_ReelLookup = state.GetComponentLookup(isReadOnly: false); - m_BossLookup = state.GetComponentLookup(isReadOnly: true); + m_FxLookup = state.GetComponentLookup(isReadOnly: false); // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. @@ -90,7 +89,7 @@ namespace ProjectM.Server m_GhostOwnerLookup.Update(ref state); m_KnockbackLookup.Update(ref state); m_ReelLookup.Update(ref state); - m_BossLookup.Update(ref state); + m_FxLookup.Update(ref state); bool haveTick = SystemAPI.TryGetSingleton(out var nt); @@ -178,7 +177,7 @@ namespace ProjectM.Server // Knockback / REEL. Reel (HookPull) stamps the HOMING ReelState (ReelSystem re-aims toward the // caster's live position each tick); otherwise the classic frozen knockback (PULL flips toward the shooter). - if (haveTick && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget)) + if (haveTick && m_KnockbackLookup.HasComponent(hitTarget)) { bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0; if (reel && m_ReelLookup.HasComponent(hitTarget)) diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs deleted file mode 100644 index e92799d24..000000000 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs +++ /dev/null @@ -1,214 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only per-ROOM enemy director — the Step-6 successor of the presence-keyed ZoneEnemyDirectorSystem. - /// While the run FSM has a room active ( == InRoom) it seeds ONE wave per - /// (int-equality reseed) sized by indexed - /// on the room's (deeper rooms + Elite/Boss types skew heavier — the - /// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring - /// around (base, ActiveSubSlot), under the same - /// "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A - /// room spawns ONE beefed boss instead (health × , - /// scale × — v1's boss is a scaled Charger). Every spawn keeps the full - /// stack — EnemyTag + RegionTag{Expedition} + — PLUS {room} (the - /// teardown contract). Scale preserved via baked.WithPosition. - /// - /// The room CLEAR edge surfaces ONLY through the replicated .State == Cleared - /// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return - /// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7). - /// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is - /// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap). - /// - /// Ordering: [UpdateAfter(RunDirectorSystem)] ONLY — reads the freshly-advanced room state same-tick. - /// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(RunDirectorSystem))] - public partial struct RoomEnemyDirectorSystem : ISystem - { - EntityQuery m_ZoneEnemies; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var runEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(runEntity); - var run = SystemAPI.GetComponent(runEntity); - bool roomActive = info.Lifecycle == RunLifecycle.InRoom; - - var directorEntity = SystemAPI.GetSingletonEntity(); - var dir = SystemAPI.GetComponent(directorEntity); - var zs = SystemAPI.GetComponent(directorEntity); - - int aliveZone = m_ZoneEnemies.CalculateEntityCount(); - - // REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD - // readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch. - if (SystemAPI.HasComponent(runEntity)) - { - byte objState; - short objRemaining; - if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0)) - { - objState = ExpeditionObjectiveState.Active; - objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue); - } - else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0) - { - objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready - objRemaining = 0; - } - else - { - objState = ExpeditionObjectiveState.Idle; - objRemaining = 0; - } - SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining }); - } - - if (!roomActive) - return; - - var prefabs = SystemAPI.GetBuffer(directorEntity); - if (prefabs.Length == 0) - return; - - // Single plan authority: the node RunDirector published — never re-derived here. - var map = RunMapMath.Generate(run.RunSeed); - var node = map.NodeAt(run.CurrentNodeId); - var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount); - byte room = (byte)(info.CurrentRoom & 0xFF); - bool bossRoom = plan.RoomType == RoomTypeId.Boss; - - var bands = new MixBands - { - GruntBase = dir.GruntsPerWave, - ChargerBase = dir.ChargersPerWave, - SpitterBase = dir.SpitterBase, - SwarmerSlotBase = dir.SwarmerSlotBase, - ChargerPerEpoch = dir.ChargerPerEpoch, - SpitterPerEpoch = dir.SpitterPerEpoch, - SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch, - SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch, - }; - - // (Re)seed once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot). - if (zs.SeededEpoch != run.RoomEpoch) - { - zs.SeededEpoch = run.RoomEpoch; - zs.SpawnCounter = 0; - zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands); - zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient - } - - if (zs.RemainingToSpawn > 0) - { - bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); - if (dueNow) - { - int slot = (int)zs.SpawnCounter; - byte kind = bossRoom ? ZoneEnemyMath.KindCharger - : ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands); - int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer - ? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1; - - // MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot). - if (aliveZone + packSize <= math.max(1, dir.MaxAlive)) - { - float3 baseCenter = new float3(0f, 1f, 0f); - if (SystemAPI.TryGetSingleton(out var anchor)) - baseCenter = BaseGridMath.PlotCenter(anchor); - float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); - float3 center = bossRoom - ? origin + new float3(0f, 0f, 12f) // the boss anchors the room center - : EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); - center.y = origin.y; - - int prefabIdx = kind; - if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively - var prefab = prefabs[prefabIdx].Prefab; - var baked = state.EntityManager.GetComponentData(prefab); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - for (int k = 0; k < packSize; k++) - { - float3 pos = packSize > 1 - ? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center; - pos.y = origin.y; - var enemy = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room); - if (bossRoom) - { - // Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the - // hitbox/reach (so hits register on the big model + its reach matches), multiply Health, - // and tag BossState (server-only discriminator) so BossAISystem alone drives it. - var bxform = baked.WithPosition(pos); - bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier; - ecb.SetComponent(enemy, bxform); - if (SystemAPI.HasComponent(prefab)) - { - // B5: party-size HP scaling by LIVING EXPEDITION players at spawn (NOT the - // RunParticipant count - dead-respawned members keep the tag while parked at - // base). Health.Max is a [GhostField] since DR-046, so the scaled max replicates. - int livingParty = 0; - foreach (var (pHealth, pRegion) in SystemAPI.Query, RefRO>().WithAll()) - if (pHealth.ValueRO.Current > 0f && pRegion.ValueRO.Region == RegionId.Expedition) livingParty++; - float partyScale = 1f + Tuning.BossHealthPerExtraPlayer * math.max(0, livingParty - 1); - var hp = SystemAPI.GetComponent(prefab); - hp.Current *= Tuning.BossHealthMultiplier * partyScale; - hp.Max *= Tuning.BossHealthMultiplier * partyScale; - ecb.SetComponent(enemy, hp); - } - if (SystemAPI.HasComponent(prefab)) - { - var hr = SystemAPI.GetComponent(prefab); - hr.Value *= Tuning.BossScaleMultiplier; - ecb.SetComponent(enemy, hr); - } - if (SystemAPI.HasComponent(prefab)) - { - var es = SystemAPI.GetComponent(prefab); - es.AttackRange *= Tuning.BossScaleMultiplier; - ecb.SetComponent(enemy, es); - } - ecb.AddComponent(enemy, new BossState { Phase = 1 }); - } - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - - zs.SpawnCounter += 1; // ONE slot consumed even for a pack - zs.RemainingToSpawn -= 1; - zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks)); - } - } - } - - SystemAPI.SetComponent(directorEntity, zs); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta deleted file mode 100644 index 263acac0f..000000000 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3204b510b450f384a93bd49902c65721 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs index ea01f7e30..e0658f80a 100644 --- a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs @@ -48,20 +48,9 @@ namespace ProjectM.Server var wave = SystemAPI.GetComponent(directorEntity); - // MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size - // curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the - // mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax. - var bands = new MixBands - { - GruntBase = director.BaseCount, - ChargerBase = director.ChargerBase, - SpitterBase = director.SpitterBase, - SwarmerSlotBase = director.SwarmerSlotBase, - ChargerPerEpoch = director.ChargerPerEpoch, - SpitterPerEpoch = director.SpitterPerEpoch, - SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch, - SwarmerPackPerEpoch = director.SwarmerPackPerEpoch, - }; + // 2026-08-07 audit purge: the 4-type weighted mix (MixBands) is gone. Its Charger/Spitter/Swarmer + // authoring was on ZERO prefabs, so every weighted slot resolved to Grunt at runtime anyway. MaxAlive + // stays the mandatory cap on the relevancy loop. // Ring centre on the base plot when present. float3 center = new float3(0f, 1f, 0f); @@ -77,7 +66,7 @@ namespace ProjectM.Server { // Start the next (bigger) wave. wave.WaveNumber += 1; - wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands); + wave.RemainingToSpawn = ZoneEnemyMath.WaveSize(wave.WaveNumber, director.BaseCount); wave.Phase = WavePhase.Spawning; wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick } @@ -89,9 +78,8 @@ namespace ProjectM.Server if (dueNow) { int slots = math.max(1, director.RingSlots); - byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands); - int packSize = kind == ZoneEnemyMath.KindSwarmer - ? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1; + const byte kind = ZoneEnemyMath.KindGrunt; + const int packSize = 1; // Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded). int aliveBase = 0; diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs index dbf5cd603..93ddd9eab 100644 --- a/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs @@ -22,7 +22,7 @@ namespace ProjectM.Server ecb.SetComponent(enemy, baked.WithPosition(pos)); // preserve the baked [GhostField] Scale ecb.AddComponent(enemy, new RegionTag { Region = region }); ecb.AddComponent(enemy); - ecb.AddComponent(enemy, new RoomTag { Room = room }); + return enemy; } } diff --git a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs index 4497d6b6e..a8c68e31d 100644 --- a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs @@ -30,7 +30,6 @@ namespace ProjectM.Server const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre ComponentLookup m_KnockbackLookup; - ComponentLookup m_BossLookup; [BurstCompile] public void OnCreate(ref SystemState state) @@ -38,7 +37,7 @@ namespace ProjectM.Server state.RequireForUpdate(); state.RequireForUpdate(); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); - m_BossLookup = state.GetComponentLookup(isReadOnly: true); + } [BurstCompile] @@ -50,7 +49,6 @@ namespace ProjectM.Server uint stamp = TickUtil.NonZero(now); uint reschedule = TickUtil.NonZero(now + ZoneEffect.PulsePeriodTicks); m_KnockbackLookup.Update(ref state); - m_BossLookup.Update(ref state); // Living enemies once this tick (entities + positions; stable query order). var enemyEntities = new NativeList(Allocator.Temp); @@ -91,7 +89,7 @@ namespace ProjectM.Server float d2 = math.lengthsq(to); if (d2 > radiusSq || d2 <= k_VortexDeadzoneSq) continue; var e = enemyEntities[i]; - if (!m_KnockbackLookup.HasComponent(e) || m_BossLookup.HasComponent(e)) continue; + if (!m_KnockbackLookup.HasComponent(e)) continue; m_KnockbackLookup[e] = new KnockbackState { Dir = math.normalize(to), diff --git a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs index 2bdcfaa70..7530417ca 100644 --- a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs +++ b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs @@ -20,7 +20,6 @@ namespace ProjectM.Server [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] public partial struct GoInGameServerSystem : ISystem { - bool _warnedMetaBlocked; // one-shot: a mis-authored subscene must not silently block spawns forever [BurstCompile] public void OnCreate(ref SystemState state) @@ -44,18 +43,9 @@ namespace ProjectM.Server // block spawns forever. GymTag switches to the clean gym path (no meta seeding; a default Spark socket // loadout below instead). The class seeds still apply (harmless: AbilityFireSystem reads sockets). bool isGym = SystemAPI.HasSingleton(); - MetaUpgradeCatalog metaCatalog = default; - DynamicBuffer metaRecord = default; - if (!isGym && (!SystemAPI.TryGetSingleton(out metaCatalog) || !metaCatalog.Value.IsCreated - || !SystemAPI.TryGetSingletonBuffer(out metaRecord, true))) - { - if (!_warnedMetaBlocked) - { - UnityEngine.Debug.LogWarning("GoInGameServerSystem: player spawn waiting on the meta catalog/director (a mis-authored subscene would block spawns forever)."); - _warnedMetaBlocked = true; - } - return; - } + // 2026-08-07 audit purge: spawning used to block until the MetaUpgradeCatalog + MetaTierState buffer + // were present (the audit's M12 — a missing catalog stranded the GoInGame RPC and no player ever + // spawned). Both are deleted, so that whole gate and its one-shot warning are gone with them. var spawner = SystemAPI.GetSingleton(); @@ -87,7 +77,9 @@ namespace ProjectM.Server ClassTraits.AppendSeeds(classId, player, ecb); // Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta // seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this). - ecb.AddComponent(player, new PlayerClass { ClassId = classId }); + // 2026-08-07 audit purge: PlayerClass was a second, server-only copy of the same byte FrameId + // already replicates (audit finding M5). It existed so the meta shop could key on it; the meta + // shop is gone, so FrameId is now the single frame identity. ecb.AddComponent(player, new FrameId { Value = classId }); // Add (not Set): baked on the real player; absent on the minimal test prefab // replicated frame/class signal // Per-frame default Spark loadout on keys 1-4 (UNCONDITIONAL since the legacy path died — without // this a non-gym spawn would have four empty sockets and no abilities at all). @@ -97,32 +89,9 @@ namespace ProjectM.Server sockets.Add(new AbilitySocket { SparkId = f1 }); sockets.Add(new AbilitySocket { SparkId = f2 }); sockets.Add(new AbilitySocket { SparkId = f3 }); - // Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as - // meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the - // ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a - // rebalanced MaxTier (D-F5). Class gate via BoonMath.MaskFor (ClassId is the normalized - // FrameKind 2/3 — a raw 1<(sender)) { var classMods = SystemAPI.GetBuffer(sender); - Entity dir2 = Entity.Null; - bool haveMeta2 = SystemAPI.TryGetSingleton(out var metaCat2) - && SystemAPI.TryGetSingletonEntity(out dir2) && SystemAPI.HasBuffer(dir2); - var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer(dir2) : default; - // DR-046: the FULL swap (class seeds + meta re-sync) lives in the shared ClassSwapUtil, - // used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift. - ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2, - out byte swNewClass); + // 2026-08-07 audit purge: the meta re-sync half of the swap went with the meta shop; + // ClassSwapUtil now re-seeds only the frame stat band. Shared with the player-facing + // ClassSelectReceiveSystem so the two paths cannot drift. + ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, out byte swNewClass); if (SystemAPI.HasComponent(sender)) SystemAPI.SetComponent(sender, new FrameId { Value = swNewClass }); - if (SystemAPI.HasComponent(sender)) - SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass }); + + ClassTraits.FrameLoadout(swNewClass, out byte sf0, out byte sf1, out byte sf2, out byte sf3); var swSockets = SystemAPI.GetBuffer(sender); swSockets.Clear(); diff --git a/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs index d702d4f75..2e2ebf3f7 100644 --- a/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs @@ -56,7 +56,7 @@ namespace ProjectM.Server var prefQ = EntityManager.CreateEntityQuery(new EntityQueryDesc { All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) }, - None = new ComponentType[] { typeof(LungeState), typeof(SpitterState) }, + Options = EntityQueryOptions.IncludePrefab, }); var prefabs = prefQ.ToEntityArray(Allocator.Temp); diff --git a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs deleted file mode 100644 index 0e73a8d14..000000000 --- a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs +++ /dev/null @@ -1,151 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative equipment handler ( / RPCs). - /// Resolves the sender's player (SourceConnection -> NetworkId -> GhostOwner, the AbilityUpgradeSystem / - /// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the - /// personal bag and the loadout (buffer index = slot), - /// and adds/strips the item's inline stat mods as s tagged by a - /// per-slot SourceId (Tuning.EquipSourceIdBase + slot), stripped TARGET-AGNOSTICALLY via - /// . (LANTERN purge: weapons are stat-sticks — the old - /// weapon->ability grant is deleted; abilities live in the 4-socket Spark loadout.) - /// - /// Effects are EVENT-DRIVEN (applied once here): StatModifier is a [GhostField] buffer re-folded by the - /// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct - /// and survives respawn (the entity persists). - /// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any - /// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server - /// SimulationSystemGroup (NOT predicted -> applied once, no rollback double-apply); only the request entity - /// destroy is deferred to the ECB. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct EquipSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAny().WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var itemDb = SystemAPI.GetSingleton(); - - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, entity) in - SystemAPI.Query>() - .WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = entity; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (request, receive, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player)) - HandleEquip(ref state, itemDb, player, request.ValueRO.ItemId); - ecb.DestroyEntity(requestEntity); - } - - foreach (var (request, receive, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player)) - HandleUnequip(ref state, itemDb, player, request.ValueRO.Slot); - ecb.DestroyEntity(requestEntity); - } - - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - } - - static bool TryResolvePlayer(ref SystemState state, NativeHashMap map, Entity conn, out Entity player) - { - player = Entity.Null; - return state.EntityManager.HasComponent(conn) - && map.TryGetValue(state.EntityManager.GetComponentData(conn).Value, out player); - } - - static void HandleEquip(ref SystemState state, ItemDatabase itemDb, Entity player, ushort itemId) - { - ref var db = ref itemDb.Value.Value; - if (!db.TryGetItem(itemId, out var def)) return; - byte slot = def.EquipSlot; - if (slot >= EquipSlotId.Count) return; // not equippable (255 or out of range) - - var bag = state.EntityManager.GetBuffer(player); - if (InventoryMath.CountOf(bag, itemId) < 1) return; // the sender isn't carrying it - - var slots = state.EntityManager.GetBuffer(player); - ushort oldItem = slots[slot].ItemId; - - // Atomicity: if the slot is occupied, the bag MUST be able to hold the swapped-out item before we - // touch anything; reject the whole equip otherwise so the old item is never lost. - if (oldItem != 0 && !InventoryMath.CanDeposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots)) - return; - - // Commit in-place. - InventoryMath.Withdraw(bag, itemId, 1); - if (oldItem != 0) - { - InventoryMath.Deposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots); - StripSlotEffects(ref state, player, slot); - } - - slots[slot] = new EquipmentSlot { ItemId = itemId }; - ApplySlotEffects(ref state, player, slot, def); - } - - static void HandleUnequip(ref SystemState state, ItemDatabase itemDb, Entity player, byte slot) - { - if (slot >= EquipSlotId.Count) return; - ref var db = ref itemDb.Value.Value; - - var slots = state.EntityManager.GetBuffer(player); - ushort item = slots[slot].ItemId; - if (item == 0) return; // nothing equipped - - var bag = state.EntityManager.GetBuffer(player); - if (!InventoryMath.CanDeposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots)) - return; // bag full -> can't unequip (no item loss) - - InventoryMath.Deposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots); - slots[slot] = new EquipmentSlot { ItemId = 0 }; - StripSlotEffects(ref state, player, slot); - } - - static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def) - { - // LANTERN purge: weapons are stat-sticks — the old weapon->AbilityRef ability grant is deleted - // (abilities live in the 4-socket Spark loadout). - var mods = state.EntityManager.GetBuffer(player); - uint sourceId = Tuning.EquipSourceIdBase + (uint)slot; - for (int i = 0; i < ItemDefBlob.MaxMods; i++) - { - var m = def.GetMod(i); - if (m.Target == 255) continue; - mods.Add(new StatModifier { Target = m.Target, Op = m.Op, Value = m.Value, SourceId = sourceId }); - } - } - - static void StripSlotEffects(ref SystemState state, Entity player, byte slot) - { - var mods = state.EntityManager.GetBuffer(player); - TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot); - } - - static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId) - => db.TryGetItem(itemId, out var d) && d.StackMax > 0 ? d.StackMax : 1; - } -} diff --git a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta deleted file mode 100644 index 8f5601297..000000000 --- a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 187144e115a815c4fae51eaa9e95012f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs deleted file mode 100644 index ccea5857e..000000000 --- a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs +++ /dev/null @@ -1,84 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative handler for RPCs: moves items from the - /// sender's PERSONAL inventory into the shared base stockpile (the global - /// the build/upgrade/automation economy spends from). Resolves the sender's - /// player (SourceConnection -> NetworkId -> GhostOwner) via the AbilityUpgradeSystem owner-map idiom, - /// then withdraws from the player's inventory and deposits into the ledger IN-PLACE (buffer mutation is not - /// a structural change). ItemId == 0 ("deposit all") is handled BEFORE any per-item withdraw and - /// never writes a 0-id row. Resolves the ledger via GetSingletonEntity<ResourceLedger>() then - /// GetBuffer<StorageEntry>() — NEVER GetSingleton<StorageEntry> (the base - /// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the - /// effect applies exactly once — no rollback double-apply). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct InventoryDepositSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = entity; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (request, receive, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - if (SystemAPI.HasComponent(conn) - && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) - { - var inv = SystemAPI.GetBuffer(player); - var req = request.ValueRO; - - if (req.ItemId == 0) - { - // Deposit EVERYTHING: drain each non-empty stack into the ledger, then clear the bag. - for (int i = 0; i < inv.Length; i++) - { - var slot = inv[i]; - if (slot.ItemId != 0 && slot.Count > 0) - StorageMath.Deposit(ledger, slot.ItemId, slot.Count); - } - inv.Clear(); - } - else - { - // Count <= 0 means "all of that item"; Withdraw clamps to what is available. - int want = req.Count <= 0 ? int.MaxValue : req.Count; - int moved = InventoryMath.Withdraw(inv, req.ItemId, want); - if (moved > 0) - StorageMath.Deposit(ledger, req.ItemId, moved); - } - } - - ecb.DestroyEntity(requestEntity); - } - - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta deleted file mode 100644 index 539d80e3e..000000000 --- a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 25b1bdf13ad8a6d4ca48bef112b98d28 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs index 12ade87a5..090e3cc16 100644 --- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs @@ -34,7 +34,6 @@ namespace ProjectM.Server const float k_ProjectileRadius = Tuning.HarvestProjectileRadius; ComponentLookup m_GhostOwnerLookup; - BufferLookup m_InvLookup; ComponentLookup m_RegionLookup; [BurstCompile] @@ -43,7 +42,6 @@ namespace ProjectM.Server state.RequireForUpdate(); state.RequireForUpdate(); m_GhostOwnerLookup = state.GetComponentLookup(true); - m_InvLookup = state.GetBufferLookup(false); m_RegionLookup = state.GetComponentLookup(true); } @@ -58,18 +56,11 @@ namespace ProjectM.Server uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u; var ledger = SystemAPI.GetBuffer(ledgerEntity); - // Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL - // inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are - // hoisted out of the per-hit sweep (invariant for the tick). + // 2026-08-07 audit purge: yield used to route to the firing player's PERSONAL inventory and spill to + // the ledger. The inventory layer went with the shell — all yield now credits the ledger directly. m_GhostOwnerLookup.Update(ref state); - m_InvLookup.Update(ref state); m_RegionLookup.Update(ref state); - bool haveDb = SystemAPI.TryGetSingleton(out var itemDb); - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, playerEntity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = playerEntity; // Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set. var tgtEntity = new NativeList(Allocator.Temp); @@ -163,13 +154,8 @@ 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. - Entity harvester = Entity.Null; - if (m_GhostOwnerLookup.HasComponent(projEntity) - && playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var ownedPlayer)) - harvester = ownedPlayer; if (deposit > 0) - HarvestMath.DepositYield(yieldId, deposit, tgtToLedger[bestIdx], harvester, - m_InvLookup, ledger, true, haveDb, itemDb); + HarvestMath.DepositYield(yieldId, deposit, ledger, true); int rem = tgtRemaining[bestIdx] - amount; tgtRemaining[bestIdx] = rem; ecb.DestroyEntity(projEntity); @@ -224,7 +210,6 @@ namespace ProjectM.Server ecb.Playback(state.EntityManager); ecb.Dispose(); - playerByConn.Dispose(); destroyed.Dispose(); tgtEntity.Dispose(); tgtPos.Dispose(); diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs deleted file mode 100644 index 49757abad..000000000 --- a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs +++ /dev/null @@ -1,215 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed ExpeditionFieldSystem. - /// When the run FSM has a room active ( == InRoom) and - /// has advanced past the epoch this system last seeded (int equality, never - /// tick math), it resolves the active room's from the map node RunDirectorSystem published - /// ( — the single plan authority; NEVER re-derived here) and scatters - /// plan.NodeCount resource nodes — FLOORED by the run-wide scarcity budget - /// , which this system spends down (documented co-write: RunDirector - /// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all - /// inside the room's shape at (base, ActiveSubSlot). Every spawn is - /// stamped {room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition}; - /// Scale is preserved via baked.WithPosition (never FromPosition). - /// - /// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps - /// ONE defensive sweep — Staging with any alive → destroy them all (idempotent; covers - /// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable. - /// - /// Ordering: [UpdateAfter(RunDirectorSystem)] so it reads the freshly-advanced room state same-tick. - /// The old inherited [UpdateAfter(CyclePhaseSystem)] is deliberately DROPPED and NO CyclePhase edge may - /// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(RunDirectorSystem))] - public partial struct RoomFieldSystem : ISystem - { - /// Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light. - const int MaxClutterPerRoom = 8; - - EntityQuery m_RoomTagged; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var dirEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(dirEntity); - var run = SystemAPI.GetComponent(dirEntity); - - var spawnerEntity = SystemAPI.GetSingletonEntity(); - var spawner = SystemAPI.GetComponent(spawnerEntity); - - // One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton. - if (!SystemAPI.HasComponent(spawnerEntity)) - { - state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState()); - return; // structural change — clean re-read next tick - } - var rf = SystemAPI.GetComponent(spawnerEntity); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - if (info.Lifecycle == RunLifecycle.InRoom) - { - if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null) - { - float3 baseCenter = new float3(0f, 1f, 0f); - if (SystemAPI.TryGetSingleton(out var anchor)) - baseCenter = BaseGridMath.PlotCenter(anchor); - float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); - - // Single plan authority: the node RunDirector published — never re-derived from the col/path. - var map = RunMapMath.Generate(run.RunSeed); - var node = map.NodeAt(run.CurrentNodeId); - var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount); - byte room = (byte)(info.CurrentRoom & 0xFF); - - // Scarcity: the run-wide budget floors this room's count and is spent down (never negative). - int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining)); - if (count > 0) - { - var baked = SystemAPI.GetComponent(spawner.Prefab); - var prefabNode = SystemAPI.GetComponent(spawner.Prefab); - var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u); - for (int i = 0; i < count; i++) - { - var e = ecb.Instantiate(spawner.Prefab); - float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); - ecb.SetComponent(e, baked.WithPosition(pos)); - // Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls, - // fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops. - var rn = prefabNode; - int roll = rng.NextInt(0, 100); - rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass; - ecb.SetComponent(e, rn); - ecb.AddComponent(e, new RoomTag { Room = room }); - } - run.NodeBudgetRemaining -= count; - SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only) - } - - // Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes. - if (SystemAPI.TryGetSingleton(out var clutter) - && clutter.Prefab != Entity.Null) - { - var cBaked = SystemAPI.GetComponent(clutter.Prefab); - var cProto = SystemAPI.GetComponent(clutter.Prefab); - var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u); - int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom); - for (int i = 0; i < cCount; i++) - { - var e = ecb.Instantiate(clutter.Prefab); - float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng); - ecb.SetComponent(e, cBaked.WithPosition(pos)); - var bc = cProto; - // ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2. - bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3); - ecb.SetComponent(e, bc); - ecb.AddComponent(e, new RoomTag { Room = room }); - } - } - - // DESTRUCTIBLE COVER (OPTIONAL singleton; review wf_e14dd739-069): never in Boss rooms (the boss - // has no depenetration backstop) and never inside the origin keep-out ring (player landing + - // portal). Variant stays the prefab's 4 — NEVER rerolled (the clutter block's explosive - // reroll must not leak into this copy). - if (plan.RoomType != RoomTypeId.Boss - && SystemAPI.TryGetSingleton(out var cover) - && cover.Prefab != Entity.Null) - { - var kBaked = SystemAPI.GetComponent(cover.Prefab); - var krng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC0Eu) | 1u); - int kCount = math.clamp(cover.Count, 0, 6); - const float KeepOutFromOrigin = 7f; - for (int i = 0; i < kCount; i++) - { - float3 pos = origin; - for (int attempt = 0; attempt < 8; attempt++) - { - pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, kCount, ref krng); - if (math.distance(pos.xz, origin.xz) >= KeepOutFromOrigin) break; - } - if (math.distance(pos.xz, origin.xz) < KeepOutFromOrigin) continue; // unlucky draws: drop the piece - var e = ecb.Instantiate(cover.Prefab); - ecb.SetComponent(e, kBaked.WithPosition(pos)); - ecb.AddComponent(e, new RoomTag { Room = room }); - } - } - - // BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT - // periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the - // cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it - // is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin. - // BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions - // desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick - // the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm). - if (plan.Biome == RoomBiomeId.Blight - && plan.RoomType != RoomTypeId.Boss - && SystemAPI.TryGetSingleton(out var geyser) - && geyser.Prefab != Entity.Null) - { - uint eruptStamp = 0u; - if (SystemAPI.TryGetSingleton(out var gnt) && gnt.ServerTick.IsValid) - eruptStamp = gnt.ServerTick.TickIndexForValidTick; - var gBaked = SystemAPI.GetComponent(geyser.Prefab); - var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u); - int gCount = math.clamp(geyser.Count, 0, 4); - const float GeyserKeepOut = 7f; - for (int i = 0; i < gCount; i++) - { - float3 pos = origin; - for (int attempt = 0; attempt < 8; attempt++) - { - pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng); - if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break; - } - if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece - var e = ecb.Instantiate(geyser.Prefab); - ecb.SetComponent(e, gBaked.WithPosition(pos)); - // born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid. - uint next = eruptStamp != 0u - ? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) - : 0u; - ecb.SetComponent(e, new Geyser { NextEruptTick = next }); - ecb.AddComponent(e, new RoomTag { Room = room }); - } - } - - rf.LastSpawnedRoomEpoch = run.RoomEpoch; - SystemAPI.SetComponent(spawnerEntity, rf); - } - } - else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty) - { - // Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room. - var ents = m_RoomTagged.ToEntityArray(Allocator.Temp); - for (int i = 0; i < ents.Length; i++) - ecb.DestroyEntity(ents[i]); - ents.Dispose(); - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta deleted file mode 100644 index efb11a6da..000000000 --- a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b2ba012b5e31bcc48b50dd14220c9fc5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs b/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs deleted file mode 100644 index a99196104..000000000 --- a/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only, one-shot spawner for the shared home-base storage container (mirrors - /// UpgradePickupSpawnSystem). On its first update it reads the baked - /// singleton and the , instantiates the container ghost at the cell center - /// (), then destroys the spawner singleton so the system idles - /// (spawned exactly once). Runs in the default SimulationSystemGroup (NOT the prediction loop); the - /// container replicates to clients as an ownerless interpolated ghost. The container is intentionally - /// NOT linked to any connection's LinkedEntityGroup, so it persists across player disconnects. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct SharedStorageSpawnSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var spawnerEntity = SystemAPI.GetSingletonEntity(); - var spawner = SystemAPI.GetComponent(spawnerEntity); - var anchor = SystemAPI.GetSingleton(); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - if (spawner.Prefab != Entity.Null) - { - var container = ecb.Instantiate(spawner.Prefab); - var position = BaseGridMath.CellToWorld(anchor, spawner.Cell); - // Phase 0: the grid Y (GridOrigin.y=1) is the CC capsule-CENTER plane; center-pivot structure - // meshes look grounded there, but the crate's pivot is at its base -> it floated 1 u. Sit it on - // the terrain surface instead. - position.y = 0f; - // Preserve the prefab's baked scale/rotation (FromPosition would reset Scale to 1). - var xform = SystemAPI.GetComponent(spawner.Prefab); - xform.Position = position; - ecb.SetComponent(container, xform); - // M6: scope the shared storage to the base region for ghost relevancy. - ecb.AddComponent(container, new RegionTag { Region = RegionId.Base }); - } - - // One-shot: remove the spawner so RequireForUpdate fails and the system idles. - ecb.DestroyEntity(spawnerEntity); - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs.meta b/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs.meta deleted file mode 100644 index ae14de78a..000000000 --- a/Assets/_Project/Scripts/Server/HomeBase/SharedStorageSpawnSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c60c2c14e48ea0c45858cf0054c1663f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs b/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs deleted file mode 100644 index e3e6f6f29..000000000 --- a/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative handler for RPCs (deposit/withdraw on the - /// shared storage container). Resolves the single as a singleton, - /// applies the op to its replicated buffer via , - /// and destroys the request entity. Runs in the default SimulationSystemGroup (NOT the prediction - /// loop), so a server event is applied exactly once (no rollback double-apply). Op is read as a byte - /// (see ); the buffer mutation auto-replicates to all clients via GhostField. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct StorageOpReceiveSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var containerEntity = SystemAPI.GetSingletonEntity(); - var contents = SystemAPI.GetBuffer(containerEntity); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (request, requestEntity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - { - var op = request.ValueRO; - if (op.Op == StorageOp.Withdraw) - StorageMath.Withdraw(contents, op.ItemId, op.Count); - else - StorageMath.Deposit(contents, op.ItemId, op.Count); - - ecb.DestroyEntity(requestEntity); - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs.meta b/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs.meta deleted file mode 100644 index 8aa886c16..000000000 --- a/Assets/_Project/Scripts/Server/HomeBase/StorageOpReceiveSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6739144c8fa1bd040ad766919f9535f3 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs deleted file mode 100644 index 66438bf07..000000000 --- a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs +++ /dev/null @@ -1,147 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for — the PERMANENT meta-upgrade purchase (Aether → tier). - /// Honored ONLY in Staging (N4: the base shop is a between-runs surface; mid-run Aether belongs to the run). - /// Per request, IN-LOOP against the live director buffers (the DR-014 placement idiom — two same-tick purchases - /// on barely-enough Aether cannot both pass): resolve sender → , validate catalog id / - /// class mask (, never raw 1<<ClassId) / MaxTier / prereq, price the NEXT tier - /// ( — tier is server-computed, never on the wire), then - /// pre-check BEFORE (Withdraw CLAMPS, it - /// never rejects), bump-or-append the row, and upsert the ABSOLUTE-value meta - /// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed Tuning.MetaSourceIdBase + id) on every - /// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via - /// GoInGameServerSystem). Success raises so the tier is on disk before a crash. - /// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct MetaSpendSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - // N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag). - bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; - - var catalog = SystemAPI.GetSingleton(); - var director = SystemAPI.GetSingletonEntity(); - if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer(director)) - accept = false; // authoring hole: drop the requests below (no withdraw happened; nothing to roll back) - - // Sender resolution (SourceConnection → NetworkId → GhostOwner → player, the ReadyToggle idiom). - var playerByConn = new NativeHashMap(8, Allocator.Temp); - // R-F2: pre-collect the live (player, class) pairs ONCE — a successful purchase upserts the modifier on - // every live member of the class, not just the buyer (shared per-class pool, operator default). - var classMembers = new NativeList(8, Allocator.Temp); - var classIds = new NativeList(8, Allocator.Temp); - foreach (var (owner, playerClass, entity) in - SystemAPI.Query, RefRO>() - .WithAll().WithEntityAccess()) - { - playerByConn[owner.ValueRO.NetworkId] = entity; - classMembers.Add(entity); - classIds.Add(playerClass.ValueRO.ClassId); - } - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, req, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not - if (!accept) continue; - - var conn = receive.ValueRO.SourceConnection; - if (!SystemAPI.HasComponent(conn) - || !playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var buyer)) - continue; - byte classId = SystemAPI.GetComponent(buyer).ClassId; - - ref var pool = ref catalog.Value.Value; - int defIdx = MetaMath.FindDef(ref pool, req.ValueRO.UpgradeId); - if (defIdx < 0) continue; // unknown id — dropped (a forged/stale request, not a crash) - ref var def = ref pool.Defs[defIdx]; - if ((def.ClassMask & BoonMath.MaskFor(classId)) == 0) continue; - - // LIVE in-loop reads (no hoist — the previous request this tick may have bumped the tier or - // drained the ledger; hoisted copies would let both pass). - var record = SystemAPI.GetBuffer(director); - byte owned = MetaMath.TierOf(record, classId, req.ValueRO.UpgradeId); - if (owned >= def.MaxTier) continue; - if (def.PrereqId != 0xFF && MetaMath.TierOf(record, classId, def.PrereqId) < def.PrereqTier) - continue; - - int cost = MetaMath.CostForTier(in def, owned); - var ledger = SystemAPI.GetBuffer(director); - if (StorageMath.TotalOf(ledger, ResourceId.Aether) < cost) continue; // pre-check: Withdraw CLAMPS - StorageMath.Withdraw(ledger, ResourceId.Aether, cost); // atomic commit (DR-014) - - byte newTier = (byte)(owned + 1); - bool bumped = false; - for (int i = 0; i < record.Length; i++) - if (record[i].ClassId == classId && record[i].UpgradeId == req.ValueRO.UpgradeId) - { - record[i] = new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }; - bumped = true; - break; - } - if (!bumped) - record.Add(new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }); - - // R-F1: ABSOLUTE-value upsert (Value = ValuePerTier * newTier) — never an incremental append; a - // second append would double-count in StatRecomputeSystem's sum. - uint sourceId = Tuning.MetaSourceIdBase + req.ValueRO.UpgradeId; - for (int p = 0; p < classMembers.Length; p++) - { - if (classIds[p] != classId) continue; - var mods = SystemAPI.GetBuffer(classMembers[p]); - bool upserted = false; - for (int m = 0; m < mods.Length; m++) - if (mods[m].SourceId == sourceId) - { - var row = mods[m]; - row.Value = def.ValuePerTier * newTier; - mods[m] = row; - upserted = true; - break; - } - if (!upserted) - mods.Add(new StatModifier - { - Target = def.Target, - Op = def.Op, - Value = def.ValuePerTier * newTier, - SourceId = sourceId, - }); - } - - // Persist immediately — the tier is real money (Aether); a crash must not eat it. - if (SystemAPI.HasComponent(director)) - SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 }); - } - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - classMembers.Dispose(); - classIds.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta deleted file mode 100644 index 69badb00a..000000000 --- a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9b277eb9da63a054db9f9b3e041d582b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs index 5320c04c8..58362e0cf 100644 --- a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -37,19 +37,12 @@ namespace ProjectM.Server rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count }; // Persist player-built structures (single shared scan; drift-proof vs the quit-to-menu writer). - uint nowTick = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick; - SaveStructureScan.Collect(EntityManager, nowTick, out var structures); - // v6: the permanent-meta slice via the ONE shared collector. - MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth); - + // 2026-08-07 audit purge: structures and the permanent-meta slice are deleted; the save now carries + // the LEDGER only. SaveData keeps its Structures / MetaUpgrades / RunsCompleted / MaxDepthReached + // fields so a v7 file written before the purge still loads — they are simply written empty now. SaveService.Save(new SaveData { - RunsCompleted = runsCompleted, - MaxDepthReached = maxDepth, - MetaUpgrades = metaRows, - Ledger = rows, - Structures = structures, SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); } diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 00b2e5df8..848a97b1a 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -49,20 +49,13 @@ namespace ProjectM.Server xform.Position = BaseGridMath.PlotCenter(anchor); ecb.SetComponent(director, xform); - // Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted - // meta counters — ALL added UNCONDITIONALLY at spawn (D-F2: a New-Game boot must have the components - // the bank block reads; Continue restores VALUES only, inside the HasData block — Step 12b). - // HostSalt starts a fixed non-tick seed lineage (bumped per launch); the save folds persisted - // RunsCompleted in at restore so cross-session runs diverge. - ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u }); - ecb.AddComponent(director, default(RouteCommand)); - ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch - - ecb.AddComponent(director, default(MetaCounters)); - - // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director - // ghost never serializes an empty ledger to clients (no replication flicker). - // DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger. + // Born-correct load: if the menu staged a save (Continue), apply the ledger AT SPAWN so the + // director ghost never serializes an empty ledger to clients (no replication flicker). + // + // 2026-08-07 audit purge: this block also seeded RunRuntime (HostSalt), RouteCommand, + // PortalCommand, MetaCounters, MetaTierState and a born-correct RunInfo mirror. All of that was + // the superseded base/expedition run-FSM and meta shop; the director now hosts exactly one thing + // — the global resource ledger. bool restoredLedger = false; if (SystemAPI.TryGetSingletonEntity(out var pendingEntity)) { @@ -72,35 +65,7 @@ namespace ProjectM.Server var srcLedger = SystemAPI.GetBuffer(pendingEntity); var destLedger = ecb.SetBuffer(director); SaveApply.WriteLedger(srcLedger, destLedger); - restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c) - - // v6: restore the permanent meta — counters (VALUES only; the component was added - // unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty - // [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown - // ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's - // sole-writer rule), and the HostSalt fold (cross-session first-run maps diverge once - // you've banked clears — the promise at the RunRuntime add). - ecb.SetComponent(director, new MetaCounters - { - RunsCompleted = pending.RunsCompleted, - MaxDepthReached = pending.MaxDepthReached, - }); - var metaSrc = SystemAPI.GetBuffer(pendingEntity); - var metaDst = ecb.SetBuffer(director); - for (int mi = 0; mi < metaSrc.Length; mi++) - metaDst.Add(new MetaTierState { ClassId = metaSrc[mi].ClassId, UpgradeId = metaSrc[mi].UpgradeId, Tier = metaSrc[mi].Tier }); - if (SystemAPI.HasComponent(spawner.Prefab)) - { - var runInfo = SystemAPI.GetComponent(spawner.Prefab); // baked Lifecycle=Staging - runInfo.RunsCompleted = pending.RunsCompleted; - runInfo.MaxDepthReached = pending.MaxDepthReached; - ecb.SetComponent(director, runInfo); - } - ecb.SetComponent(director, new RunRuntime - { - HostSalt = RunMapMath.Hash(0x5EED0001u, (uint)pending.RunsCompleted + 1u), - }); - + restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore } ecb.DestroyEntity(pendingEntity); } diff --git a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs deleted file mode 100644 index dc7a73258..000000000 --- a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs +++ /dev/null @@ -1,66 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for — a participant interacting the room-exit portal during - /// RoomExplore. Honored ONLY when RunInfo.Lifecycle==RoomExplore and the sender is an EXPEDITION player - /// (region gate, the RouteSelect idiom). Sets the server-only latch IN-PLACE; it does - /// NOT write RunInfo or tear the room down — RunDirectorSystem (the sole FSM/teardown owner) consumes the latch and - /// advances. Plain server group, before RunDirectorSystem; requests ALWAYS destroyed; NO CyclePhase edge. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct PortalInteractReceiveSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - var b = new EntityQueryBuilder(Allocator.Temp).WithAll(); - state.RequireForUpdate(state.GetEntityQuery(b)); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var dirEntity = SystemAPI.GetSingletonEntity(); - bool gateOpen = SystemAPI.GetComponent(dirEntity).Lifecycle == RunLifecycle.RoomExplore; - - // Sender region lookup (N3 idiom): a base-bound joiner cannot pull the party out of the room. - var regionByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, region) in - SystemAPI.Query, RefRO>().WithAll()) - regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region; - - bool interacted = SystemAPI.GetComponent(dirEntity).HasInteract != 0; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, requestEntity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - bool valid = gateOpen && !interacted - && SystemAPI.HasComponent(conn) - && regionByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out byte senderRegion) - && senderRegion == RegionId.Expedition; - if (valid) - { - SystemAPI.SetComponent(dirEntity, new PortalCommand { HasInteract = 1 }); - interacted = true; - } - ecb.DestroyEntity(requestEntity); - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - regionByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta deleted file mode 100644 index 00d4109e1..000000000 --- a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cfb4147e08bf1b244bef1fa5d71d8b9e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs deleted file mode 100644 index 69750d111..000000000 --- a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for : resolves the sender (SourceConnection → NetworkId → - /// GhostOwner → player entity, the AbilityUpgradeSystem idiom) and SETS . - /// Honored ONLY while the run FSM is in Staging or Launching — an un-ready during the Launching countdown is the - /// launch-abort escape hatch (RunDirectorSystem reverts to Staging); toggles arriving mid-run are dropped (the - /// Returning edge clears every flag anyway). Ordered BEFORE RunDirectorSystem so a toggle lands the same tick the - /// ready-count is derived. Plain server group (one-off RPC effects never run in the predicted loop); the request - /// entity is ALWAYS destroyed. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct ReadyToggleSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - byte lifecycle = SystemAPI.GetSingleton().Lifecycle; - bool accept = lifecycle == RunLifecycle.Staging || lifecycle == RunLifecycle.Launching; - - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = entity; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, req, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - if (accept - && SystemAPI.HasComponent(conn) - && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) - { - SystemAPI.SetComponent(player, new PlayerReady { Value = (byte)(req.ValueRO.Ready != 0 ? 1 : 0) }); - } - ecb.DestroyEntity(requestEntity); - } - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta deleted file mode 100644 index d373060a5..000000000 --- a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7392579cf8b92e64f9686b58da99f7c2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs deleted file mode 100644 index 9510cc389..000000000 --- a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs +++ /dev/null @@ -1,97 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server receiver for — the co-op route choice (any-player-first-commits, the - /// operator's locked authority model). Validates each pick server-authoritatively: - /// Lifecycle == RouteSelect · run identity (uint)ForRunEpoch == RunRuntime.RunSeed (the Step-8 - /// review re-mean: the replicated seed IS the run token; the server-only RunEpoch is not client-knowable) · - /// ForLayer == RunInfo.CurrentRoom (the gate's un-incremented cleared layer) · OptionIndex within - /// the replicated RouteOptionCount · the SENDER's is Expedition (N3 — a - /// base-bound joiner cannot commit the party's route) · nothing accepted yet this gate. - /// - /// FIRST-COMMIT LATCH: the accepted pick is written to the server-only via an - /// IMMEDIATE in-place SystemAPI.SetComponent INSIDE the drain loop plus a local accepted flag (the DR-014 - /// atomicity idiom) — two same-tick picks can never both observe an open gate; a hoisted read would re-create - /// the exact N1 race the design review killed. is stamped from the TRUE - /// server-only epoch (never the client-echoed value). Requests are ALWAYS destroyed. This system writes ONLY - /// RouteCommand — RunDirectorSystem stays the sole RunInfo/RunRuntime writer and consumes the latch - /// (abort → pick → grace, in that order). Ordered before it so a pick can land the same tick it is consumed; - /// NO CyclePhase edge (the room-chain hard rule). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(RunDirectorSystem))] - public partial struct RouteSelectSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var dirEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(dirEntity); - var run = SystemAPI.GetComponent(dirEntity); - bool gateOpen = info.Lifecycle == RunLifecycle.RouteSelect; - - // Sender-region lookup (N3): connection NetworkId -> the player's CURRENT region. A player entity - // without RegionTag simply never enters the map -> its pick is a clean reject, never a throw. - var regionByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, region) in - SystemAPI.Query, RefRO>().WithAll()) - regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region; - - // Local accepted flag beside the in-place write = the first-commit latch (nothing else writes - // RouteCommand mid-loop; RunDirector's gate-entry clear ran on a previous tick by construction). - bool accepted = SystemAPI.GetComponent(dirEntity).HasPick != 0; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, req, requestEntity) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - bool valid = gateOpen - && !accepted - && (uint)req.ValueRO.ForRunEpoch == run.RunSeed - && req.ValueRO.ForLayer == info.CurrentRoom - && req.ValueRO.OptionIndex < info.RouteOptionCount - && SystemAPI.HasComponent(conn) - && regionByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out byte senderRegion) - && senderRegion == RegionId.Expedition; - - if (valid) - { - // IMMEDIATE in-place commit (never an ECB-deferred write) + the local flag: first pick wins. - SystemAPI.SetComponent(dirEntity, new RouteCommand - { - HasPick = 1, - OptionIndex = req.ValueRO.OptionIndex, - ForRunEpoch = run.RunEpoch, // the TRUE server epoch — never echo the client value - ForLayer = req.ValueRO.ForLayer, - }); - accepted = true; - } - - ecb.DestroyEntity(requestEntity); - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - regionByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta deleted file mode 100644 index 4a7c5c9f6..000000000 --- a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f56898976ef03af499c200e0d6f43b0d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs deleted file mode 100644 index 2ac93f927..000000000 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs +++ /dev/null @@ -1,438 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// SOLE writer of the replicated run-lifecycle FSM () and its server-only working state - /// (). - /// - /// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) → - /// InRoom (fight; the clear edge arrives as the replicated .State == Cleared, - /// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no - /// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via ; - /// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong - /// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive) - /// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route - /// choice (RouteSelect) replaces the fixed col-0 advance at Step 8. - /// - /// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth - /// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine - /// boss-clear terminal () credits RunsCompleted and requests a - /// save. An abort/wipe banks NOTHING but the depth high-water (D-F3). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - public partial struct RunDirectorSystem : ISystem - { - /// "All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts. - const uint LaunchCountdownTicks = 180; - - /// Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses - /// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10). - const uint RewardGraceTicks = 1800; - - /// Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it - /// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2). - const uint RouteGraceTicks = 1800; - - EntityQuery m_RoomTagged; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var dirEntity = SystemAPI.GetSingletonEntity(); - var info = SystemAPI.GetComponent(dirEntity); - var run = SystemAPI.GetComponent(dirEntity); - - float3 baseCenter = new float3(0f, 1f, 0f); - if (SystemAPI.TryGetSingleton(out var anchor)) - baseCenter = BaseGridMath.PlotCenter(anchor); - - // Ready-check + party-presence derivation, shared across the states below. The party is co-located at - // base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a - // disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean. - int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0; - foreach (var (ready, region) in - SystemAPI.Query, RefRO>().WithAll()) - { - totalPlayers++; - if (ready.ValueRO.Value != 0) readyPlayers++; - if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++; - } - bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers; - - switch (info.Lifecycle) - { - case RunLifecycle.Staging: - { - if (allReady && run.WasAllReady == 0) - { - // Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage, - // never a tick, never 0, equality-compared downstream. - run.RunEpoch += 1; - run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch); - run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt)); - run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget; - run.RoomsClearedThisRun = 0; - run.BoonPickCounter = 0; // fresh boon-band provenance per run - run.LastTerminalCleared = 0; - - var map = RunMapMath.Generate(run.RunSeed); - info.RunSeed = run.RunSeed; - info.RoomCount = map.LayerCount; - info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks); - info.Lifecycle = RunLifecycle.Launching; - } - run.WasAllReady = (byte)(allReady ? 1 : 0); - break; - } - - case RunLifecycle.Launching: - { - // Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch). - if (!allReady) - { - info.LaunchTick = 0u; - info.Lifecycle = RunLifecycle.Staging; - run.WasAllReady = 0; - break; - } - - bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick); - if (due) - { - // Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at - // base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner - // is never yanked into the fight; the tag is released on the Returning edge. - var conscript = new EntityCommandBuffer(Allocator.Temp); - foreach (var (_, playerE) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - conscript.AddComponent(playerE); - conscript.Playback(state.EntityManager); - conscript.Dispose(); - - // Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0). - var map = RunMapMath.Generate(run.RunSeed); - EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true); - info.LaunchTick = 0u; - } - break; - } - - case RunLifecycle.InRoom: - { - // All expedition players gone (disconnect/death-warp edge) → clean abort, no credit. - if (expeditionPlayers == 0) - { - run.LastTerminalCleared = 0; - info.Lifecycle = RunLifecycle.Returning; - break; - } - - // The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick - // (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the - // next room spawns on the advance tick → ≥1 empty tick, exactly one room alive. - if (SystemAPI.HasComponent(dirEntity) - && SystemAPI.GetComponent(dirEntity).State == ExpeditionObjectiveState.Cleared) - { - // DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through - // RoomReward + the loot window so the party can mine after clearing. - - run.RoomsClearedThisRun += 1; - if (info.CurrentRoom >= info.RoomCount - 1) - run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear - run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks); - info.Lifecycle = RunLifecycle.RoomReward; - } - break; - } - - case RunLifecycle.RoomReward: - { - if (expeditionPlayers == 0 && run.LastTerminalCleared == 0) - { - info.Lifecycle = RunLifecycle.Returning; - break; - } - - // Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10) - // OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4). - // Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who - // died and respawned to base must not stall the party (post-impl review, confirmed major). - bool anyPending = false; - foreach (var (offer, pregion) in - SystemAPI.Query, RefRO>().WithAll()) - if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0) - { anyPending = true; break; } - bool graceElapsed = run.RewardGraceTick == 0u - || !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick); - if (anyPending && !graceElapsed) - break; - - run.RewardGraceTick = 0u; - // NO offer survives this gate: zero every straggler (a dead-respawned base player the - // auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the - // HUD modal open or stall a later reward gate (post-impl review, confirmed major). - foreach (var offer in SystemAPI.Query>().WithAll()) - offer.ValueRW = default; - // DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist - // (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout. - run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks); - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window - info.Lifecycle = RunLifecycle.RoomExplore; - break; - } - - case RunLifecycle.RoomExplore: - { - // DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a - // participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft - // timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell). - if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win - { // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment. - run.ExploreGraceTick = 0u; - info.Lifecycle = RunLifecycle.Returning; - break; - } - bool portalUsed = SystemAPI.HasComponent(dirEntity) - && SystemAPI.GetComponent(dirEntity).HasInteract != 0; - bool exploreTimedOut = run.ExploreGraceTick == 0u - || !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick); - if (!portalUsed && !exploreTimedOut) - break; // still looting - - run.ExploreGraceTick = 0u; - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, default(PortalCommand)); - - // The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance. - var exploreEcb = new EntityCommandBuffer(Allocator.Temp); - RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF)); - exploreEcb.Playback(state.EntityManager); - exploreEcb.Dispose(); - - if (run.LastTerminalCleared != 0) - { - info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner - } - else - { - // Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable - // options; RouteSelect is the teardown gap (the room is gone now). - var map = RunMapMath.Generate(run.RunSeed); - int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols); - if (optionCount == 0) - { - info.RouteOptionCount = 0; - info.Lifecycle = RunLifecycle.Returning; - } - else - { - int nextLayer = info.CurrentRoom + 1; - info.RouteOptionCount = (byte)math.min(optionCount, 3); - info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0; - info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0; - info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0; - info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0; - info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0; - info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0; - run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks); - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, default(RouteCommand)); - info.Lifecycle = RunLifecycle.RouteSelect; - } - } - break; - } - - -case RunLifecycle.RouteSelect: - { - // Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick - // from a vanishing party must never resurrect the run (EnterRoom would conscript base players); - // an accepted pick must beat a same-tick grace expiry (the player was told "committed"). - if (expeditionPlayers == 0) - { - info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3) - run.LastTerminalCleared = 0; - info.Lifecycle = RunLifecycle.Returning; - break; - } - - var cmd = SystemAPI.HasComponent(dirEntity) - ? SystemAPI.GetComponent(dirEntity) - : default; - bool routeGraceElapsed = run.RouteGraceTick == 0u - || !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick); - - if (cmd.HasPick != 0) - { - // The party's committed choice (first-accepted-wins latch; any-player-first-commits). - byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col - : cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col; - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, default(RouteCommand)); - run.RouteGraceTick = 0u; - var map = RunMapMath.Generate(run.RunSeed); - EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true); - } - else if (routeGraceElapsed) - { - // AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first). - run.RouteGraceTick = 0u; - var map = RunMapMath.Generate(run.RunSeed); - EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true); - } - break; - } - - case RunLifecycle.Returning: - { - // PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes - // home (a mid-run joiner already at base keeps its position); the tag removal re-opens the - // next run's conscription cleanly (post-impl review, confirmed medium). - int idx = 0; - var homebound = new EntityCommandBuffer(Allocator.Temp); - foreach (var (region, xform, playerE) in - SystemAPI.Query, RefRW>() - .WithAll().WithEntityAccess()) - { - region.ValueRW.Region = RegionId.Base; - var p = baseCenter; - p.x += 1.5f * idx; - p.y = xform.ValueRO.Position.y; - xform.ValueRW.Position = p; - homebound.RemoveComponent(playerE); - idx++; - } - homebound.Playback(state.EntityManager); - homebound.Dispose(); - - // THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3). - if (run.LastBankedRunEpoch != run.RunEpoch) - { - run.LastBankedRunEpoch = run.RunEpoch; - - // Always: the honest depth high-water (actual rooms cleared, never the planned count). - if (SystemAPI.HasComponent(dirEntity)) - { - var meta = SystemAPI.GetComponent(dirEntity); - meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun); - if (run.LastTerminalCleared != 0) - meta.RunsCompleted += 1; - SystemAPI.SetComponent(dirEntity, meta); - info.RunsCompleted = meta.RunsCompleted; // HUD mirror - info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror - } - - // Boss-clear only: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge). - if (run.LastTerminalCleared != 0 && SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 }); - } - - // TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every - // 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 - // disjoint and survive. Idempotent — safe on every Returning tick. - foreach (var (mods, timed, fx, offer) in - SystemAPI.Query, DynamicBuffer, RefRW, RefRW>().WithAll()) - { - TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, - Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan); - TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase, - 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; - } - - // Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone. - foreach (var ready in SystemAPI.Query>().WithAll()) - ready.ValueRW.Value = 0; - - run.RewardGraceTick = 0u; - run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either - run.WasAllReady = 0; - run.LastTerminalCleared = 0; - - info.CurrentRoom = 0; - info.RouteOptionCount = 0; - info.LaunchTick = 0u; - info.Lifecycle = RunLifecycle.Staging; - break; - } - } - - // Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never - // freeze stale behind a branch's early-break). - SystemAPI.SetComponent(dirEntity, info); - SystemAPI.SetComponent(dirEntity, run); - } - - /// - /// Enter room (, ): publish the node as the single plan - /// authority (/CurrentRoomType — the field/enemy directors NEVER - /// re-derive it), flip the ping-pong sub-slot, bump so the room systems - /// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition). - /// - void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map, - int layer, int col, float3 baseCenter, bool bumpEpoch) - { - var node = map.Node(layer, col); - run.ActiveSubSlot = (byte)(layer & 1); - run.CurrentNodeId = RunMap.NodeId(layer, col); - run.CurrentCol = (byte)col; - run.CurrentRoomType = node.RoomType; - if (bumpEpoch) - run.RoomEpoch += 1; - - info.CurrentRoom = layer; - info.CurrentCol = (byte)col; - info.CurrentRoomType = node.RoomType; - info.CurrentBiome = node.Biome; - info.RouteOptionCount = 0; - - float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); - int idx = 0; - foreach (var (region, xform) in - SystemAPI.Query, RefRW>().WithAll()) - { - region.ValueRW.Region = RegionId.Expedition; - var p = roomOrigin; - p.x += 1.5f * idx; // small spread so kinematic capsules don't stack - p.y = xform.ValueRO.Position.y; - xform.ValueRW.Position = p; - idx++; - } - - info.Lifecycle = RunLifecycle.InRoom; - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta deleted file mode 100644 index bc3751a4d..000000000 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b7c36de338378264e9aa0ad2c2512e7f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs b/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs deleted file mode 100644 index b8227afd7..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client -> server request to build a structure of at grid cell - /// (, ). A one-off action, so an RPC (mirrors StorageOpRequest). - /// StructureType is a byte; the cell is two int scalars (NOT an int2) to stay - /// within the project's scalar-only RPC payload precedent (avoids first-of-its-kind composite-math-in-RPC - /// codegen risk on Netcode 1.x). The server re-validates legality + cost authoritatively. - /// - public struct BuildPlaceRequest : IRpcCommand - { - public byte StructureType; - public int CellX; - public int CellZ; - public byte Direction; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs.meta deleted file mode 100644 index 865c3be23..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: dbcc491dc3dd853459cd8cfad2458b17 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs b/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs deleted file mode 100644 index 91ae68735..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Unity.Collections; -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// Pure, deterministic build-placement helpers (unit-tested like / - /// StorageMath). Occupancy is DERIVED from the live structure set each placement (the structure - /// ghosts are the source of truth — restart- and replay-order-safe), never cached on the immutable - /// baked . The server passes a Temp of occupied - /// cells built by scanning live ghosts. - /// - public static class BuildPlacementMath - { - /// True if is occupied in the derived set. - public static bool IsOccupied(in NativeHashSet occupied, int2 cell) => occupied.Contains(cell); - - /// Full server placement legality: the cell is in-plot (half-open, negative-safe) AND not occupied. - public static bool CanPlace(in BaseAnchor anchor, in NativeHashSet occupied, int2 cell) - { - return BaseGridMath.IsCellInPlot(anchor, cell) && !occupied.Contains(cell); - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs.meta b/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs.meta deleted file mode 100644 index 88f61feba..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPlacementMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 203dcbd4f9cc089408633b0bb6ccb2c1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs deleted file mode 100644 index abcb1f777..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// Pure validity check for the client build-placement PREVIEW (the ground-ghost colour) — the same legality - /// the server re-validates authoritatively in BuildPlaceSystem, computed client-side so the ghost can read - /// green (valid) vs red (why-not). No managed types / RNG / wall-clock → unit-testable. The caller supplies - /// the live occupancy result + the affordability inputs (it owns the structure scan + the ledger read). - /// - public static class BuildPreviewMath - { - public const byte Valid = 0; - public const byte OutOfPlot = 1; - public const byte Occupied = 2; - public const byte Unaffordable = 3; - - /// - /// Evaluate placement at : must be in-plot, unoccupied, and affordable. - /// = the caller's live-structure cell check; / - /// the resource on hand vs the catalog cost. Returns the first failing reason, else . - /// - public static byte Evaluate(in BaseAnchor anchor, int2 cell, bool occupied, int have, int cost) - { - if (!BaseGridMath.IsCellInPlot(anchor, cell)) return OutOfPlot; - if (occupied) return Occupied; - if (have < cost) return Unaffordable; - return Valid; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta deleted file mode 100644 index 1e5bcddb2..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4deb41f803f65bd4b946354e4a2adcf2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs b/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs deleted file mode 100644 index cd3854dc3..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Structure type ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). Ids 1-4 are - /// RETIRED (EB-2 turret defense + M7 automation machines, deleted in the LANTERN purge) and stay reserved - /// so PlacedStructure.Type's [GhostField] serializer + old save bytes never re-mean. - /// - public static class StructureType - { - public const byte None = 0; - // RETIRED ids — reserved, do not reuse: - public const byte Turret = 1; - public const byte Harvester = 2; - public const byte Fabricator = 3; - public const byte Conveyor = 4; - // Live buildables: - public const byte Wall = 5; - public const byte Pylon = 6; - } - - /// - /// A built base structure occupying one grid cell. An ownerless INTERPOLATED ghost (RegionTag{Base}, - /// world-owned, runtime-spawned by BuildPlaceSystem). is the only replicated field - /// (a cheap byte for client visual branching); is server-only (clients derive it from - /// the replicated LocalTransform via , so it stays off the wire). - /// / are server-only raw NetworkTick values - /// (-guarded; 0 = inactive), kept for future timed structures (the turret - /// cooldown + production catch-up that used them are retired — LANTERN purge). - /// - public struct PlacedStructure : IComponentData - { - /// Structure type (see ); the only replicated field. - [GhostField] public byte Type; - - /// Occupied grid cell (server-only; clients derive it from LocalTransform). - public int2 Cell; - - /// Next action tick (server-only). 0 = inactive. - public uint NextTick; - - /// Last tick this structure was processed (server-only). Stamped at spawn. - public uint LastProcessedTick; - } - - /// - /// One row of the build catalog: cost + prefab per structure type. Modeled on AbilityPrefabElement - /// (prefab baked via GetEntity, NEVER inside a blob — blobs don't remap entity refs). - /// - public struct StructureCatalogEntry : IBufferElementData - { - public byte Type; - public Entity Prefab; - public byte CostResourceId; - public int CostAmount; - } - - /// Tag on the baked singleton carrying the buffer (the build cost/prefab table). - public struct StructureCatalog : IComponentData { } - - /// - /// Marks a structure PLACED by a player at runtime (BuildPlaceSystem) or restored from a save — i.e. the - /// persistable set, as opposed to anything baked into the subscene. SaveWriteSystem scans only these and - /// BaseRestoreSystem re-adds the tag, so save/restore is the single source of truth for player builds. - /// Server-only (not replicated). (Re-homed here from the retired automation components — LANTERN purge.) - /// - public struct RuntimePlacedTag : IComponentData { } -} - diff --git a/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs.meta deleted file mode 100644 index 0c8db296b..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/StructureComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 00d3379caf4807d4ebd97432848dd5d5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 85d068555..5bfddea6f 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -19,7 +19,7 @@ namespace ProjectM.Simulation /// AbilitySocket (loadout), SocketCooldown (hot per-socket cooldown), EffectiveSocketStats (per-socket /// folded stats from StatRecomputeSystem). To stay under the 7-type SystemAPI.Query cap, the query holds /// only PlayerInput/PlayerFacing/LocalTransform/GhostOwner and reads the socket data by entity via - /// BufferLookup/ComponentLookup (mirroring the BoonEffects lookup). + /// BufferLookup/ComponentLookup.okup. /// /// SpawnId key (owner14 | socket2 | fireCount12 | fork4) reserves socket bits so two sockets firing the /// same-prefab projectile on one tick classify to DISTINCT ghosts (the review's NP-1/RS-1/DB-3 fix). The @@ -37,11 +37,9 @@ namespace ProjectM.Simulation [BurstCompile] public partial struct AbilityFireSystem : ISystem { - // Server-only knockback stamp for the Cone (guarded + boss-immune). + // Server-only knockback stamp for the Cone.ss-immune). ComponentLookup m_KnockbackLookup; - ComponentLookup m_BossLookup; - // Owner-replicated mechanic-changer boons, read by the player entity. - ComponentLookup m_BoonEffectsLookup; + // Boss knockback-immunity and BoonEffects lookups deleted 2026-08-07 (audit purge). // LANTERN socket kit, read by the player entity so the fire query stays at 4 type args (7-arg cap). BufferLookup m_SocketLookup; ComponentLookup m_SocketCdLookup; @@ -65,8 +63,7 @@ namespace ProjectM.Simulation state.RequireForUpdate(); state.RequireForUpdate(); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); - m_BossLookup = state.GetComponentLookup(isReadOnly: true); - m_BoonEffectsLookup = state.GetComponentLookup(isReadOnly: true); + m_SocketLookup = state.GetBufferLookup(isReadOnly: true); m_SocketCdLookup = state.GetComponentLookup(isReadOnly: false); m_EffSocketLookup = state.GetBufferLookup(isReadOnly: true); @@ -96,8 +93,7 @@ namespace ProjectM.Simulation var tcfg = SystemAPI.TryGetSingleton(out var tcv) ? tcv : TuningConfig.Defaults(); uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks); m_KnockbackLookup.Update(ref state); - m_BossLookup.Update(ref state); - m_BoonEffectsLookup.Update(ref state); + m_SocketLookup.Update(ref state); m_SocketCdLookup.Update(ref state); m_EffSocketLookup.Update(ref state); @@ -135,8 +131,8 @@ namespace ProjectM.Simulation var effSockets = m_EffSocketLookup[entity]; var cd = m_SocketCdLookup[entity]; // struct copy; written back after mutation - BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default; - bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0; + // Boons deleted 2026-08-07 (audit purge): pull was the KnockToPull mechanic-changer. + const bool pull = false; // 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the // MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing). @@ -154,7 +150,7 @@ namespace ProjectM.Simulation { float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId, - serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup); } m_ConePendingLookup[entity] = default; // consume (drop on mismatch) } @@ -223,7 +219,7 @@ namespace ProjectM.Simulation { float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId, - serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup); } m_ConePendingLookup[entity] = new ConeContactPending { @@ -236,7 +232,7 @@ namespace ProjectM.Simulation // Legacy immediate (knob 0, or a plain test world without the baked pending slot). float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId, - serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup); } } cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks))); @@ -333,10 +329,10 @@ namespace ProjectM.Simulation dir = AutoTarget.Resolve(xform.ValueRO.Position, rawAim, es.AutoTargetRange, es.AutoTargetConeRadians, candidates); } - byte pierce = bfx.Pierce; - byte chain = bfx.Chain; + byte pierce = 0; // boon Pierce deleted 2026-08-07 + byte chain = 0; // boon Chain deleted 2026-08-07 byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags); - int shots = 1 + math.min((int)bfx.Fork, 8); + int shots = 1; // boon Fork deleted 2026-08-07 for (int s = 0; s < shots; s++) { @@ -391,7 +387,7 @@ namespace ProjectM.Simulation static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId, NetworkTick serverTick, bool pull, in NativeList coneTargets, in NativeList coneTargetPos, ref EntityCommandBuffer ecb, - ref ComponentLookup knockbackLookup, in ComponentLookup bossLookup) + ref ComponentLookup knockbackLookup) { float cRange = math.max(0.1f, es.Range); float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f)); @@ -406,7 +402,7 @@ namespace ProjectM.Simulation SourceNetworkId = ownerNetId, SourceTick = cStamp, }); - KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci], + KnockbackUtil.Stamp(ref knockbackLookup, coneTargets[ci], casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull); } diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs deleted file mode 100644 index 10b477ff7..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs +++ /dev/null @@ -1,302 +0,0 @@ -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (==0 — - /// // map 1:1 onto a row, the - /// original path) OR a Phase-1.7 MECHANIC-CHANGER (==1 — selects the - /// hook; for the stacking kinds Pierce/Fork/Chain is the per-pick count delta, else it's a - /// flag). Bytes, never enums, on the baked path. is the stable key the replicated - /// BoonOffer options + pick RPC carry. is the rarity draw weight (common 100 / - /// uncommon 60 / rare 30 / epic 10). : bit0 = Warrior (classId 0), bit1 = Ranger - /// (classId 1), 3 = both. tags synergy/dedup — no two same-family options in one deal. - /// - public struct BoonDefBlob - { - public byte Id; - public byte Target; // StatTarget as byte (Kind==0) - public byte Op; // ModOp as byte (Kind==0) - public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta - public byte Weight; - 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 FixedString128Bytes Desc; - } - - /// - /// Synergy/dedup tags for . 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). - /// - 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; - } - - /// The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern). - public struct BoonCatalogBlob - { - public BlobArray Defs; - } - - /// Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene). - public struct BoonCatalog : IComponentData - { - public BlobAssetReference Value; - } - - /// - /// Server-only bookkeeping for BoonOfferSystem, attached at runtime beside the catalog singleton (the - /// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room. - /// - public struct BoonOfferState : IComponentData - { - public int OfferedRoomEpoch; - } - - /// - /// Pure, deterministic boon selection math — integer-hash only (RunMapMath.Hash chain, no RNG state), so - /// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is - /// safe because BoonOfferSystem draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch) - /// — the client never re-runs it. EditMode-tested. - /// - public static class BoonMath - { - /// Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger). - public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1)); - - /// - /// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per - /// (, ). Phase 1.7: a non-stacking FLAG effect the player - /// already owns is excluded (dedup); no two options share a 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). - /// - public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool, - out byte o0, out byte o1, out byte o2) - { - byte classBit = MaskFor(classId); - int ownedFamilies = OwnedFamilyMask(owned); - - var candidates = new FixedList128Bytes(); // catalog indices - var weights = new FixedList128Bytes(); // biased draw weight per candidate (parallel) - int totalWeight = 0; - 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].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); - weights.Add((byte)w); - totalWeight += w; - } - - o0 = o1 = o2 = 0; - if (candidates.Length == 0) - return 0; - - var picked = new FixedList32Bytes(); // picked catalog indices - var pickedFamilies = new FixedList32Bytes(); // families used this deal (fam != 0) - uint salt = 0; - while (picked.Length < 3 && picked.Length < candidates.Length) - { - uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight; - int chosen = candidates.Length - 1; - int acc = 0; - for (int c = 0; c < candidates.Length; c++) - { - acc += weights[c]; - if (roll < (uint)acc) { chosen = c; break; } - } - byte drawn = candidates[chosen]; - byte fam = pool.Defs[drawn].Family; - - bool dup = false; - for (int p = 0; p < picked.Length; p++) - 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 && !famClash) - { - picked.Add(drawn); - if (fam != 0) pickedFamilies.Add(fam); - salt = 0; - } - else if (++salt > 16) - { - // Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible). - AddFallback(ref picked, ref pickedFamilies, candidates, ref pool); - salt = 0; - } - } - - o0 = picked.Length > 0 ? pool.Defs[picked[0]].Id : (byte)0; - o1 = picked.Length > 1 ? pool.Defs[picked[1]].Id : o0; - o2 = picked.Length > 2 ? pool.Defs[picked[2]].Id : o1; - return picked.Length; - } - - /// 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). - static void AddFallback(ref FixedList32Bytes picked, ref FixedList32Bytes pickedFamilies, - in FixedList128Bytes 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); - } - - /// 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. - 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; - } - } - - /// Bitmask (indexed by 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). - 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; - } - - /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). - public static int FindDef(ref BoonCatalogBlob pool, byte id) - { - for (int i = 0; i < pool.Defs.Length; i++) - if (pool.Defs[i].Id == id) return i; - return -1; - } - } - - /// - /// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers - /// (==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). - /// - public static class BoonCatalogData - { - /// Build the default catalog blob (caller owns/disposes the reference). - public static BlobAssetReference BuildDefault(Allocator allocator = Allocator.Persistent) - { - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var defs = builder.Allocate(ref root.Defs, 12); - int i = 0; - // ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only - // (the Warrior's Fire is a cone, not a projectile). ---- - defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy"); - defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread"); - defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy"); - defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE"); - defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through"); - defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN"); - defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you"); - defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities"); - // ---- 4 strong flat-stat boons (Kind=0) ---- - defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "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(allocator); - builder.Dispose(); - return blob; - } - - /// A flat-stat boon row (Kind=0 — appends a ). - static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family, - string name, string desc) - { - return new BoonDefBlob - { - Id = id, - Target = (byte)target, - Op = (byte)op, - Value = value, - Weight = weight, - ClassMask = mask, - Kind = 0, - EffectKind = BoonEffectKind.None, - Family = family, - Name = new FixedString64Bytes(name), - Desc = new FixedString128Bytes(desc), - }; - } - - /// A mechanic-changer boon row (Kind=1 — mutates ). - /// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects. - 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), - Desc = new FixedString128Bytes(desc), - }; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta deleted file mode 100644 index ef05dedf3..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 00909311d983d7a43afc195595aff217 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs deleted file mode 100644 index 2ceeb5729..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat - /// band. Stackable counts (//) - /// and boolean (see ) that combat systems read to alter behaviour. - /// - /// Replicated (matching BoonOffer): rollback-correctness is - /// provided by the [GhostField]s themselves — the owner is the sole predicting client and needs the - /// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in AbilityFireSystem, which - /// filters .WithAll<Simulate>()) 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 — the send type is not what enables rollback, the - /// [GhostField] is. - /// - /// Baked INERT (all 0) on the player prefab (the BoonOffer idiom) so a pick is a non-structural mutate; - /// zeroed on the Returning edge in RunDirectorSystem alongside the StatModifier band strips. - /// - [GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] - public struct BoonEffects : IComponentData - { - /// Extra enemy hits a projectile survives before despawning (stacks). - [GhostField] public byte Pierce; - /// Extra spread projectiles spawned per shot (stacks). - [GhostField] public byte Fork; - /// Targets a projectile chains to after a hit (stacks). - [GhostField] public byte Chain; - /// Boolean effect bits — see . - [GhostField] public byte Flags; - } - - /// Bit masks for . Plain byte consts (never an enum compared in Burst). - 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 - } - - /// - /// Stable byte discriminator for a BoonDefBlob mechanic-changer effect (0 = a plain stat boon). - /// Bytes only — Burst-safe, never an enum compared inside a Bursted system. - /// - 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; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta deleted file mode 100644 index c0df804d6..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonEffects.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 76c925707efba46478fb9c697d391e0d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs deleted file mode 100644 index 177ba19bf..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server boon pick: (0/1/2) into the sender's OWN replicated BoonOffer - /// options. Server-validated (Pending==1, index in range, RunInfo.Lifecycle==RoomReward — the - /// D-F4 gate that stops a grace-timeout straggler pick landing after the Returning-edge strip). UNCONDITIONAL - /// wire type, blittable scalar. Declared at Step 3 (wire front-load); consumed by BoonApplySystem from - /// Step 10. - /// - public struct BoonPickRequest : IRpcCommand - { - /// The chosen option slot: 0, 1, or 2. - public byte Index; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta deleted file mode 100644 index e391550df..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cc9680d4a4c9a334396b60bb97d75b3b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs deleted file mode 100644 index 67917d275..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that 's - /// RoomEnemyDirectorSystem tags at spawn). NOT replicated and NOT baked — added at runtime via ECB on the boss - /// entity, so it needs no ghost-hash change (a runtime-added replicated component would not replicate anyway; - /// this one is deliberately server-only, like /). - /// - /// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of - /// .WithAll<EnemyTag, BossState>(), and EnemyAISystem's Charger MOVE pass excludes it via - /// .WithNone<BossState>() so exactly one system writes the boss's Position/AttackWindup. The boss does - /// NOT use LungeState (its signature move is a telegraphed radial SLAM, not a lunge) — so EnemyAISystem's - /// IsLunging derive visits it but sees LungeState.UntilTick==0 and derives the bit off (harmless single - /// writer). is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule). - /// All tick fields route through TickUtil.NonZero and compare with . - /// - /// - public struct BossState : IComponentData - { - /// 1 = phase one (heavy Charger + slam), 2 = phase two (<50% HP: faster + summons adds). Byte, not enum. - public byte Phase; - - /// Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready). - public uint SlamReadyTick; - - /// Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready). - public uint SummonReadyTick; - - /// Earliest raw tick the boss may begin its next LUNGE wind-up (B4; NonZero; 0 = ready). - public uint LungeReadyTick; - - /// Which attack the live AttackWindup belongs to: 0 = radial slam, 1 = lunge (B4 — slam and lunge - /// share the one replicated windup field; this server-only byte disambiguates the elapse branch). The client - /// distinguishes via the IsLunging ghost bit instead (BossAISystem holds LungeState.UntilTick through the - /// lunge wind-up + travel, so EnemyAISystem's derive turns the bit on). - public byte PendingAttack; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta deleted file mode 100644 index bb3a7d94d..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fdf576e1f07162e43bae89c4ccc06dec \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs index 485e8876c..4d4d3f9fa 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs @@ -3,57 +3,25 @@ using Unity.Entities; namespace ProjectM.Simulation { /// - /// The ONE in-place class-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing - /// base ClassSelect (Staging). A class swap is much more than re-seeding: the pre-code review (DR-046) confirmed - /// that swapping only the class-seed band leaves the OLD class's PERMANENT META rows on the buffer and omits the - /// NEW class's — so a base swap would drain Aether into the wrong class's record and mis-set Max HP. This helper - /// mirrors the (previously editor-only) full swap: (class-seed band) + the meta - /// band strip + per-class replay. The caller then writes AbilityRef/PlayerClass/ - /// AbilityCooldown and calls (a static can't resolve singletons or SystemAPI.SetComponent, - /// so the caller passes the resolved pieces). Server-authoritative + prediction-correct (StatRecomputeSystem - /// refolds EffectiveCharacterStats next tick). + /// The ONE in-place frame-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing + /// frame select. Re-seeds the frame's stat band via ; the caller then writes + /// FrameId/PlayerClass, re-seeds the socket loadout, and calls (a static can't resolve + /// singletons or SystemAPI.SetComponent, so the caller passes the resolved pieces). Server-authoritative + + /// prediction-correct (StatRecomputeSystem refolds EffectiveCharacterStats next tick). + /// + /// HISTORY (2026-08-07 audit purge): this also used to strip and replay a PERMANENT-META band + /// (MetaUpgradeCatalog + MetaTierState) so an Aether-bought upgrade followed the frame across a swap. The meta + /// shop belonged to the superseded base/expedition direction and was deleted; only the frame band remains. /// public static class ClassSwapUtil { - /// Re-seed the class band + re-sync the permanent-meta band for on - /// . Returns the normalized class + its Fire ability id (the caller sets AbilityRef). - /// false (no catalog/record) skips the meta replay (the strip still runs). - /// Re-seed the class band + re-sync the permanent-meta band for on - /// . Returns the normalized class (the caller writes FrameId/PlayerClass + re-seeds - /// the socket loadout). false (no catalog/record) skips the meta replay (the - /// strip still runs). - public static void Apply(byte rawClass, DynamicBuffer mods, - bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer metaRecord, - out byte newClass) + /// Re-seed the frame stat band for on . + /// Returns the normalized frame id (the caller writes FrameId/PlayerClass + re-seeds the socket + /// loadout). + public static void Apply(byte rawClass, DynamicBuffer mods, out byte newClass) { newClass = ClassTraits.Normalize(rawClass); ClassTraits.Reapply(newClass, mods); - - // Strip the OLD class's meta rows (Reapply only touched the class-seed band), then replay the NEW class's - // persisted tiers (the GoInGame skip/clamp rules) so the permanent channel stays correct across the swap. - TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.MetaSourceIdBase, - Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan); - if (haveMeta && metaCat.Value.IsCreated && metaRecord.IsCreated) - { - ref var metaPool = ref metaCat.Value.Value; - byte metaBit = BoonMath.MaskFor(newClass); - for (int mi = 0; mi < metaRecord.Length; mi++) - { - if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue; - int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId); - if (defIdx < 0) continue; - if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue; - byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier - ? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier; - mods.Add(new StatModifier - { - Target = metaPool.Defs[defIdx].Target, - Op = metaPool.Defs[defIdx].Op, - Value = metaPool.Defs[defIdx].ValuePerTier * metaTier, - SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId, - }); - } - } } /// Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs deleted file mode 100644 index b90cf2f62..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Unity.Entities; -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the - /// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock - /// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO - /// Health, so it is invisible to every WithAll<Health> target loop (player melee/projectile hit-tests can - /// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by - /// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem. - /// - public struct EnemyProjectile : IComponentData - { - /// Planar heading (world XZ -> float2 x,y), unit length, locked at spawn. - public float2 Direction; - - /// Travel speed (world units/second). - public float Speed; - - /// Damage applied to the first valid same-region target hit. - public float Damage; - - /// Max travel distance before it expires (world units). - public float Range; - - /// Accumulated travelled distance (server-only; drives range-expiry). - public float DistanceTravelled; - - /// Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the - /// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the - /// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling. - public float LastStep; - - /// Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any - /// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER - /// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard. - public byte Region; - } - - /// - /// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it - /// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab). - /// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap - /// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern). - /// - public struct SpitterProjectilePrefab : IComponentData - { - public Entity Prefab; - public int MaxLiveProjectiles; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta deleted file mode 100644 index d7e486332..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8a31a7b0c834ae24db480005ffdb6a15 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs b/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs index 6b83bca08..8f83e959d 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/KnockbackUtil.cs @@ -5,20 +5,23 @@ namespace ProjectM.Simulation { /// /// Shared knockback stamp for melee/cone hits. Guarded exactly as the two call sites were: the target must own - /// (dummies/structures lacking it would throw at ECB playback if written) and must - /// NOT be a boss ( = knockback-immune, A4). The planar (XZ) heading is - /// normalize(targetPos - sourcePos), falling back to when that delta is - /// degenerate. Deduplicates the identical stamps in (Warrior cone) and - /// (melee cleave). Callers still gate their own speed/window (e.g. the melee + /// (dummies/structures lacking it would throw at ECB playback if written). The + /// planar (XZ) heading is normalize(targetPos - sourcePos), falling back to + /// when that delta is degenerate. Deduplicates the identical stamps in (cone) + /// and (melee cleave). Callers still gate their own speed/window (e.g. the melee /// KnockSpeed > 0 check) before calling. + /// + /// The former BossState knockback-immunity gate was removed with the boss purge (2026-08-07 audit): the boss + /// query required LungeState, which no prefab baked, so BossAISystem matched nothing and the immunity branch + /// was unreachable. Reintroduce a per-target immunity flag when the LANTERN shelf-boss lands (Phase 6). /// static class KnockbackUtil { - public static void Stamp(ref ComponentLookup lookup, in ComponentLookup bossLookup, + public static void Stamp(ref ComponentLookup lookup, Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick, bool pull = false) { - if (!lookup.HasComponent(target) || bossLookup.HasComponent(target)) - return; + if (!lookup.HasComponent(target)) + return;; float3 delta = targetPos - sourcePos; float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback; diff --git a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs b/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs deleted file mode 100644 index 8148fc771..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger - /// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): - /// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch - /// (which excludes these via .WithNone<LungeState>()). On a wind-up commit the Charger LOCKS - /// toward the target and travels at until — dealing - /// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot). - /// NOT a [GhostField] (the lunged position replicates via the stock LocalTransform variant, like - /// KnockbackState). All ticks via TickUtil.NonZero; compared with only. - /// - public struct LungeState : IComponentData - { - /// Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y). - public float2 Dir; - - /// Lunge speed (world units/s); only meaningful while is active. - public float Speed; - - /// Raw tick the lunge ends (NonZero). 0 = not lunging. Active while .IsNewerThan(serverTick). - public uint UntilTick; - - /// Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not - /// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit - /// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself - /// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish. - public uint StaggerUntilTick; - } - - /// - /// REPLICATED enableable MID-LUNGE flag on a Charger (Slice 1, Feature D). ENABLED for exactly the ticks a - /// Charger is committed to its locked-direction lunge ( active), DISABLED - /// otherwise. The ONLY replicated Charger surface beyond the stock LocalTransform — a [GhostEnabledBit], - /// NOT a [GhostField], because the client needs only on/off: the lunge HEADING is already carried by the - /// replicated LocalTransform.Rotation (EnemyAISystem writes LookRotationSafe(lungeDir) each lunge tick), so the - /// client indicator derives direction via AnimParamMath.PlanarForward like the danger cone already does. Fixes - /// the cue VANISHING at commit (AttackWindup zeroes on commit, so a windup-gated cone disappears exactly when - /// the danger is realest): this bit STAYS on through the committed travel. Server-derived once per tick from - /// LungeState.UntilTick in EnemyAISystem (the sole LungeState writer); BAKE DISABLED (a Charger spawns - /// not-lunging) + visit via .WithPresent<IsLunging>() to write the bit while disabled (the Dead idiom). - /// - [GhostEnabledBit] - public struct IsLunging : IComponentData, IEnableableComponent { } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs.meta deleted file mode 100644 index df4a0d469..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cc65446b98bef1040bc5b9beaac094ba \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs deleted file mode 100644 index 7fc39c760..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition - /// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic - /// .{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG -> - /// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus - /// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition) - /// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one - /// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old - /// 2-type / exactly (a parity test - /// pins this, so the base-siege size curve is provably unchanged where it must be). - /// - public struct MixBands : IComponentData - { - public int GruntBase; - public int ChargerBase; - public int SpitterBase; - public int SwarmerSlotBase; - public int ChargerPerEpoch; - public int SpitterPerEpoch; - public int SwarmerSlotPerEpoch; - - /// Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds - /// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size. - public int SwarmerPackPerEpoch; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta deleted file mode 100644 index bad4a8a30..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 850f904d96b1c7d41959dddbdbf0b4b5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs deleted file mode 100644 index 023338735..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace ProjectM.Simulation -{ - /// One base "prep loadout" option: spend a base resource before launch for a RUN-SCOPED stat buff - /// (stripped on the Returning edge like a boon). Mechanical fields only — the HUD supplies display labels. - public struct PrepRow - { - public byte Id; - public byte CostResId; // ResourceId.* - public int Cost; - public byte Target; // StatTarget - public byte Op; // ModOp - public float Value; - } - - /// - /// The base PREP-LOADOUT catalog (DR-046): the player funds each run's power from base resources at Staging. A - /// purchase appends ONE run-scoped in the prep SourceId band - /// ( + Id), which 's PrepPurchaseSystem gates once-per-run - /// by that SourceId's PRESENCE (its lifetime == the band, stripped on Returning — so it re-buys next run for free, - /// no separate latch). A plain managed static table (read by the non-Burst receiver + the managed HUD). - /// - public static class PrepCatalog - { - public static readonly PrepRow[] Rows = - { - new PrepRow { Id = 0, CostResId = ResourceId.Ore, Cost = 30, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f }, - new PrepRow { Id = 1, CostResId = ResourceId.Biomass, Cost = 40, Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.12f }, - new PrepRow { Id = 2, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.PercentMult, Value = 0.20f }, - new PrepRow { Id = 3, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentMult, Value = 0.20f }, - }; - - public static int Count => Rows.Length; - - public static bool TryGet(byte id, out PrepRow row) - { - for (int i = 0; i < Rows.Length; i++) - if (Rows[i].Id == id) { row = Rows[i]; return true; } - row = default; - return false; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta deleted file mode 100644 index a6547d3f7..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ef0c16b1e46d22c42bf38db14b2983b5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs deleted file mode 100644 index efb90d330..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server: buy a base PREP-LOADOUT option ( id). Honored ONLY in Staging; the - /// server prices it from the catalog (never on the wire), does an in-loop - /// pre-check BEFORE (DR-014 atomicity), and appends the run-scoped - /// once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type. - /// - public struct PrepPurchaseRequest : IRpcCommand - { - /// Prep-catalog option id. - public byte OptionId; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta deleted file mode 100644 index eff8797ee..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e945968f38977974f926709051f28609 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs deleted file mode 100644 index 0c56bda2f..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no - /// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant - /// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger - /// branch (the AI partitions Spitter = .WithAll<EnemyTag,SpitterState>().WithNone<LungeState>() so no - /// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too - /// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If - /// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField] - /// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only. - /// - public struct SpitterState : IComponentData - { - /// Band centre: the distance the Spitter tries to hold from its target (world units). - public float PreferredRange; - - /// Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds. - public float RangeTolerance; - - /// Muzzle speed baked onto the spit projectile (world units/second). - public float ProjectileSpeed; - - /// If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back. - public float CorneredRange; - - /// Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not - /// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot. - public int WindupTicks; - - /// Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its - /// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan. - public uint NextShotTick; - } - - /// - /// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the - /// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The - /// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps - /// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged. - /// - public struct SwarmerTag : IComponentData { } -} diff --git a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta deleted file mode 100644 index 2329aa000..000000000 --- a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: be9404154fd4f964099918079d2da6b8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs index d1098a9fb..6b29531ca 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs @@ -3,101 +3,32 @@ using Unity.Mathematics; namespace ProjectM.Simulation { /// - /// Pure, deterministic composition math for the expedition zone-enemy wave — no RNG state, no wall-clock — so the - /// per-epoch wave is reproducible across restarts/saves and EditMode-unit-testable without an ECS world (mirrors - /// / ProductionMath). The highest-leverage Slice-3 variety lever: the encounter - /// COMPOSITION shifts grunt-heavy -> charger-heavy as the expedition epoch climbs (grunt count stays - /// fixed; the per-epoch growth is all chargers). + /// Pure, deterministic wave-size math — no RNG state, no wall-clock — so a wave is reproducible across + /// restarts/saves and EditMode-unit-testable without an ECS world (mirrors ). + /// + /// HISTORY (2026-08-07 audit purge): this class used to carry a 4-kind weighted composition + /// (Grunt/Charger/Spitter/Swarmer) driven by a MixBands struct. The Charger/Spitter/Swarmer authoring + /// components were on ZERO prefabs, so LungeState/SpitterState/SwarmerTag were never baked and every + /// branch of that math resolved to Grunt at runtime — the escalation curve was inert while 734 lines of + /// green tests certified it. The composition layer was deleted; the LANTERN bestiary (Drowner, Grindylow, + /// Wrecker, Wisp-Choir) will reintroduce variety through the CreatureKit path, not through this file. + /// Recover the old version from git if the weighted-slot model is wanted again. /// public static class ZoneEnemyMath { - /// - /// Total enemies in this epoch's wave: the baked + - /// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied - /// expedition always has a fight. is the monotonic sortie counter (>=1 in practice). - /// - public static int WaveSize(int epoch, int gruntsPerWave, int chargersPerWave) - { - int e = math.max(1, epoch); - int baseCount = math.max(0, gruntsPerWave) + math.max(0, chargersPerWave); - return math.max(1, baseCount + (e - 1)); - } - - /// - /// Deterministic grunt/charger pick for spawn of this epoch's wave. The charger - /// count is + (epoch - 1), clamped to the wave size, assigned to the LAST - /// slots; everything earlier is a Grunt. So the grunt count stays fixed at - /// and the wave skews charger-heavy as the epoch climbs. Returns true for a Charger slot. Stable per - /// (epoch, slot) — a replayed wave is identical. Pure integer math (Burst-safe; no enum, no RNG). - /// - public static bool IsChargerSlot(int epoch, int slot, int gruntsPerWave, int chargersPerWave) - { - int e = math.max(1, epoch); - int size = WaveSize(epoch, gruntsPerWave, chargersPerWave); - int chargers = math.clamp(math.max(0, chargersPerWave) + (e - 1), 0, size); - int s = ((slot % size) + size) % size; - return s >= size - chargers; - } - - // ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ---- - // Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted). + /// The single enemy kind. Directors index a per-Kind prefab buffer by this byte; kept as a + /// byte (not an enum) because EnemyAISystem is Bursted and cross-assembly enums trip Burst. public const byte KindGrunt = 0; - public const byte KindCharger = 1; - public const byte KindSpitter = 2; - public const byte KindSwarmer = 3; /// - /// Total SLOTS in this epoch/wave under : GruntBase + the per-kind ramped counts - /// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight. - /// A swarmer SLOT expands to a pack at spawn (), so this counts packs, not - /// individual swarmers. For the LEGACY band it equals (parity-tested). Pure integer. + /// Total enemies in this wave: plus one extra per epoch beyond the first + /// (a gentle ramp). Lower-bounded at 1 so an occupied arena always has a fight. + /// is the monotonic wave counter (>=1 in practice). Pure integer math; Burst-safe. /// - public static int WaveSlots(int epoch, in MixBands bands) + public static int WaveSize(int epoch, int baseCount) { int e = math.max(1, epoch); - int grunts = math.max(0, bands.GruntBase); - int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1)); - int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1)); - int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1)); - return math.max(1, grunts + chargers + spitters + swarmers); - } - - /// - /// Deterministic Kind byte for spawn of this epoch/wave. Slots are partitioned in a - /// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy - /// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds - /// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this - /// returns KindCharger on exactly the slots the old did (parity-tested). Pure. - /// - public static byte KindForSlot(int epoch, int slot, in MixBands bands) - { - int e = math.max(1, epoch); - int size = WaveSlots(epoch, bands); - int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1)); - int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1)); - int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1)); - int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor - - int s = ((slot % size) + size) % size; - if (s < grunts) return KindGrunt; - s -= grunts; - if (s < spitters) return KindSpitter; - s -= spitters; - if (s < chargers) return KindCharger; - s -= chargers; - if (s < swarmers) return KindSwarmer; - return KindGrunt; // defensive: unreachable while counts sum to size - } - - /// - /// Swarmer cluster size for a swarmer slot: plus the (default-0) - /// ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack; - /// the field is exposed for later tuning. - /// - public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize) - { - int e = math.max(1, epoch); - return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1)); + return math.max(1, math.max(0, baseCount) + (e - 1)); } } } diff --git a/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs index 954453980..bc422f91c 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/HarvestMath.cs @@ -1,4 +1,3 @@ -using Unity.Collections; using Unity.Entities; namespace ProjectM.Simulation @@ -6,49 +5,29 @@ 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. + /// hard-coded a stack cap and silently ignored per-item limits.) All yield credits the shared + /// directly. + /// + /// HISTORY (2026-08-07 audit purge): yield used to route to a PERSONAL InventorySlot bag for expedition-region + /// targets and spill to the ledger. The inventory/equipment layer was already marked PAUSED in CLAUDE.md and + /// belonged to the superseded base/expedition direction, so it was deleted along with the shell; harvest is now + /// single-sink. When LANTERN's carried-vs-banked cargo distinction lands (Phase 2), reintroduce the second sink + /// here rather than at the two call sites — that is the whole point of this class. /// 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. + /// Routes one harvested yield to the shared ledger. Returns true if the yield landed somewhere; callers use + /// this to avoid consuming a target for zero credit (e.g. no ledger singleton present). + /// 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) + public static bool DepositYield(byte yieldId, int amount, DynamicBuffer ledger, bool haveLedger) { - int remainder = amount; - bool deposited = false; + if (amount <= 0 || !haveLedger) + return 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; + StorageMath.Deposit(ledger, yieldId, amount); + return true; } } } diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs b/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs deleted file mode 100644 index 87c4a05b5..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Tag marking the shared home-base storage container. All state lives in the entity's - /// buffer. In M5 there is exactly one (server-spawned at a fixed base - /// cell), so server systems resolve it as a singleton. Server-authoritative and world-resident, so - /// its contents survive a player disconnect (no disk persistence yet). - /// - public struct SharedStorageContainer : IComponentData { } -} diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs.meta b/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs.meta deleted file mode 100644 index ade5acea7..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/SharedStorageContainer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8de8d91f5d8f0a64c87b0847ae85c564 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs b/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs deleted file mode 100644 index 5513a0c16..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client -> server request to deposit into or withdraw from the shared storage container. A one-off - /// action, so it is an RPC (not a per-tick predicted input). Op is stored as a byte (see - /// ) rather than an enum to keep the generated serializer trivial and avoid the - /// cross-assembly enum-codegen hazard. No target entity is carried: M5 has a single shared container, - /// which the server resolves as a singleton (entity refs are not stable across worlds). - /// - public struct StorageOpRequest : IRpcCommand - { - /// Operation code (see ): 0 = deposit, 1 = withdraw. - public byte Op; - - /// Item to deposit/withdraw. - public ushort ItemId; - - /// Quantity to deposit/withdraw (server clamps withdraw to available). - public int Count; - } - - /// Operation codes for (byte to keep RPC serialization trivial). - public static class StorageOp - { - /// Add items to the shared container. - public const byte Deposit = 0; - - /// Remove items from the shared container. - public const byte Withdraw = 1; - } -} diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs.meta b/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs.meta deleted file mode 100644 index 0f7faf105..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageOpRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: dc9ec88867d746e45b9204331b5bab51 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs b/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs deleted file mode 100644 index 479ea81d1..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Unity.Entities; -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// Singleton baked into the gameplay subscene, holding the baked storage-container ghost prefab and - /// the base-grid cell to spawn it at. A one-shot server system instantiates the prefab at - /// BaseGridMath.CellToWorld(anchor, Cell) and then destroys this singleton. Mirrors the - /// UpgradePickupSpawner / PlayerSpawner pattern. - /// - public struct StorageSpawner : IComponentData - { - /// Baked storage-container ghost prefab to instantiate. - public Entity Prefab; - - /// Base-grid cell at which to place the container (cell center, on the base plane). - public int2 Cell; - } -} diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs.meta b/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs.meta deleted file mode 100644 index 9773825a9..000000000 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageSpawner.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6a2e4fad83fa03b4890d736b388a9917 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs deleted file mode 100644 index 0822e181c..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client -> server request to equip an item from the sender's personal inventory into the slot the - /// catalog assigns it (). A one-off action, so it is an RPC (not a - /// per-tick predicted input); applied exactly once server-only in the plain SimulationSystemGroup. Carries - /// only the ItemId — the server derives the target slot from the catalog, so a client can't force a weapon - /// into the armor slot. Unconditional wire type (no #if); the server resolves the sender via SourceConnection. - /// - public struct EquipRequest : IRpcCommand - { - /// The inventory item to equip; the server resolves its slot + effects from the catalog. - public ushort ItemId; - } - - /// - /// Client -> server request to unequip whatever occupies (an ), - /// returning the item to the personal inventory and stripping its effects. Unconditional wire type. - /// - public struct UnequipRequest : IRpcCommand - { - /// The to clear. - public byte Slot; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta deleted file mode 100644 index 6178d2e29..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ad5475188aa05de45a231f937b19f069 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs deleted file mode 100644 index 32f59bbda..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ProjectM.Simulation -{ - /// - /// Equipment-slot ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). The player's - /// buffer holds one row PER slot in this fixed order (the buffer index IS the - /// slot), so these double as both the catalog's value and the buffer - /// index. The Weapon slot grants its item's into AbilityRef; - /// every slot grants the item's inline stat mods. is reserved for Phase 2 (tool-gated - /// harvesting). 255 = not equippable. - /// - public static class EquipSlotId - { - /// Weapon: grants the item's ability (AbilityRef.Id) + its stat mods. - public const byte Weapon = 0; - - /// Armor: grants the item's stat mods. - public const byte Armor = 1; - - /// Trinket: grants the item's stat mods. - public const byte Trinket = 2; - - /// Tool (axe/pickaxe) — reserved for Phase 2 tool-gated harvesting. - public const byte Tool = 3; - - /// Number of equipment slots (the baked buffer length). - public const byte Count = 4; - - /// Sentinel: this item is not equippable. - public const byte None = 255; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta deleted file mode 100644 index 773deb6c2..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ce8addc5ae6832a449d1b6ad459ab21a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs deleted file mode 100644 index 538b174f7..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// One equipment slot on the player. The per-player buffer holds exactly - /// rows in fixed slot order (the buffer INDEX is the slot — Weapon=0/Armor=1/Trinket=2/Tool=3), so only the - /// equipped item id needs to replicate; there is no separate Slot field to desync. A [GhostField] - /// buffer (a / twin) - /// so the owning client's HUD can show its loadout; the server is the SOLE writer (EquipSystem). - /// - /// The actual effects — AbilityRef.Id from the Weapon slot + StatModifiers per slot — are applied - /// EVENT-DRIVEN by EquipSystem (once per equip/unequip), NOT re-derived from this buffer each tick; this - /// buffer is the replicated record of WHAT is equipped (HUD-facing + persistence-ready), not the effect. - /// NOTE: adding this [GhostField] buffer changes the player ghost serialization hash → the player - /// prefab/subscene MUST be re-baked consistently in both worlds (see ). - /// - [GhostComponent(OwnerSendType = SendToOwnerType.All)] - [InternalBufferCapacity(4)] - public struct EquipmentSlot : IBufferElementData - { - /// Item equipped in this slot (0 = empty). The buffer INDEX is the . - [GhostField] public ushort ItemId; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta deleted file mode 100644 index e0ec06559..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ac0f8812307d3ef43bbed63d1e3fb737 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs deleted file mode 100644 index 26a3ca809..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client -> server request to move items from the sender's PERSONAL inventory into the shared base - /// stockpile (the global the build/upgrade/automation economy spends from). - /// A one-off action, so it is an RPC (not a per-tick predicted input), and the server applies it exactly - /// once in the plain SimulationSystemGroup (no rollback double-apply). Payload is plain blittable scalars - /// (no entity refs, no enum): the server resolves the sender's player from the RPC's SourceConnection. The - /// wire type is UNCONDITIONAL (never #if-gated) so the RpcCollection hash matches across release/dev peers; - /// only the send/receive SYSTEMS may be #if-gated. - /// - public struct InventoryDepositRequest : IRpcCommand - { - /// Item to deposit, or 0 to deposit EVERYTHING the player is carrying. The server branches on - /// 0 BEFORE any per-item withdraw and never writes a 0-id row. - public ushort ItemId; - - /// Quantity to deposit; <= 0 means "all of that item" (ignored when ItemId is 0). - public int Count; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta deleted file mode 100644 index 1bf61bf2b..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a815da9a948230e46bc4f7154887613e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs deleted file mode 100644 index 9183a387c..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Pure, deterministic stacking logic for a player's buffer (no RNG / - /// wall-clock / singleton access, so server and any future prediction agree). Parallels - /// in spirit but does NOT collapse into it: StorageMath is an unbounded - /// single-row merge, whereas this enforces a per-item stack cap and a max slot count and supports multiple - /// stacks of the same item once a stack fills. DynamicBuffer is a handle, so mutations apply to the - /// underlying entity buffer; growing it (buffer.Add) is a resize, NOT a structural change, so it is safe to - /// call while iterating a different query. Unit-tested in EditMode via a plain Entities world. - /// - public static class InventoryMath - { - /// - /// Add of : first tops up existing non-full stacks - /// of that item, then appends new stacks (each capped at ) while a free slot - /// remains (buffer length < ). Returns the REMAINDER that did not fit - /// (0 if everything was deposited). No-op (returns 0) for count <= 0; a positive count of itemId 0 - /// returns the full count (nothing deposited — never writes a 0-id row). stackMax < 1 = unbounded. - /// - public static int Deposit(DynamicBuffer buffer, ushort itemId, int count, int stackMax, int maxSlots) - { - if (count <= 0) return 0; - if (itemId == 0) return count; - if (stackMax < 1) stackMax = int.MaxValue; - - // Top up existing stacks of this item. - for (int i = 0; i < buffer.Length && count > 0; i++) - { - if (buffer[i].ItemId != itemId) continue; - var e = buffer[i]; - int space = stackMax - e.Count; - if (space <= 0) continue; - int add = space < count ? space : count; - e.Count += add; - buffer[i] = e; - count -= add; - } - - // Append new stacks while a slot is free. - while (count > 0 && buffer.Length < maxSlots) - { - int add = stackMax < count ? stackMax : count; - buffer.Add(new InventorySlot { ItemId = itemId, Count = add }); - count -= add; - } - - return count; - } - - /// - /// Remove up to of across all its stacks, clamped to - /// what is available; drops a stack that reaches zero. Returns the amount actually withdrawn (0 if none). - /// Iterates back-to-front so RemoveAt does not skip a stack. No-op for count <= 0 or itemId 0. - /// - public static int Withdraw(DynamicBuffer buffer, ushort itemId, int count) - { - if (count <= 0 || itemId == 0) return 0; - - int taken = 0; - for (int i = buffer.Length - 1; i >= 0 && count > 0; i--) - { - if (buffer[i].ItemId != itemId) continue; - var e = buffer[i]; - int t = e.Count < count ? e.Count : count; - e.Count -= t; - taken += t; - count -= t; - if (e.Count <= 0) - buffer.RemoveAt(i); - else - buffer[i] = e; - } - return taken; - } - - /// - /// Non-mutating check: would depositing of FULLY fit - /// (top-up existing stacks + new stacks within )? Used by the equip swap to - /// guarantee the swapped-out item has room BEFORE any withdrawal (no item loss). Mirrors Deposit's space math. - /// - public static bool CanDeposit(DynamicBuffer buffer, ushort itemId, int count, int stackMax, int maxSlots) - { - if (count <= 0) return true; - if (itemId == 0) return false; - if (stackMax < 1) stackMax = int.MaxValue; - - long space = 0; - for (int i = 0; i < buffer.Length; i++) - if (buffer[i].ItemId == itemId) - space += stackMax - buffer[i].Count; - int freeSlots = maxSlots - buffer.Length; - if (freeSlots > 0) - space += (long)freeSlots * stackMax; - return space >= count; - } - - /// Total quantity of across all stacks (0 if absent). - public static int CountOf(DynamicBuffer buffer, ushort itemId) - { - int total = 0; - for (int i = 0; i < buffer.Length; i++) - if (buffer[i].ItemId == itemId) - total += buffer[i].Count; - return total; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta deleted file mode 100644 index e46f72a25..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c8560f6c2e717b943bed78d40ea87404 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs deleted file mode 100644 index acbfee33d..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// One (item, count) row in a player's PERSONAL inventory. The per-player DynamicBuffer of these is the - /// server-authoritative source of what that player is carrying. A structural twin of - /// : a [GhostField] buffer with so the owning - /// (predicting) client receives its own inventory — without it the owner, being the owner, would not get - /// the owner-typed buffer at all and the HUD would read empty. BOTH fields carry [GhostField]; the - /// [GhostComponent] attribute alone does NOT auto-replicate fields (an un-annotated field ships as a - /// silent zero), so the annotations mirror field-for-field. - /// - /// REPLICATION DISCIPLINE — the ONLY writers are server-only: - /// (harvest yield) and the deposit-to-base RPC handler, both in the plain server SimulationSystemGroup. So - /// there is no predicted-loop double-apply and the owner never mispredicts its inventory — it is a pure - /// server-authored snapshot. NEVER mutate this from a client predicted system (that would reintroduce a - /// double-apply / mispredict path). ItemId is the same opaque ushort id space as - /// and the catalog. - /// - /// NOTE: adding this [GhostField] buffer CHANGES the player ghost serialization hash — the player prefab / - /// subscene MUST be re-baked (consistently in both worlds) or the connect handshake desyncs. - /// - [GhostComponent(OwnerSendType = SendToOwnerType.All)] - [InternalBufferCapacity(Tuning.InventoryMaxSlots)] - public struct InventorySlot : IBufferElementData - { - /// Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op). - [GhostField] public ushort ItemId; - - /// Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath). - [GhostField] public int Count; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta deleted file mode 100644 index c572f67be..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f151c780df9917d4089b2944f3ffb12d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs deleted file mode 100644 index e88978457..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace ProjectM.Simulation -{ - /// - /// Broad item-category ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard that - /// already de-Bursted ProjectileClassificationSystem). The category lets systems and UI treat an item - /// generically (a resource stacks and is spendable at the base; a tool/weapon is equippable; a - /// consumable is used) without a per-id switch. Stored in the ItemDatabase blob's . - /// - public static class ItemCategory - { - /// Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting. - public const byte Resource = 0; - - /// Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2). - public const byte Tool = 1; - - /// Weapon. Equipping it grants its ability + stat modifiers (Phase 1). - public const byte Weapon = 2; - - /// Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1). - public const byte Gear = 3; - - /// One-shot consumable (potion/charge). Used from the inventory (later phase). - public const byte Consumable = 4; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta deleted file mode 100644 index 3e2f36f17..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fa1718754184d2a418b1099ef7e3ae34 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs deleted file mode 100644 index 11e185d41..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Singleton handle to the baked item-definition database (config, not replicated — baked identically - /// into both worlds from the gameplay subscene, exactly like ). A distinct - /// component type, so GetSingleton<ItemDatabase>() resolves independently of the ability - /// database (singleton-ness is per type). Optional at runtime: consumers that read it use - /// TryGetSingleton and fall back to defaults (e.g. Tuning.DefaultStackMax) so the sim still - /// runs before the catalog is authored. - /// - public struct ItemDatabase : IComponentData - { - public BlobAssetReference Value; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta deleted file mode 100644 index 34e81bb20..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6b355888562be7349b8754168375db9b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs deleted file mode 100644 index 501d41b11..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// One authored item definition, baked immutable into the blob. This is the - /// single source of truth for everything an item IS — resources, tools, weapons, gear, consumables — so - /// adding game content is an authoring row + re-bake, with no code change. Id space is the SAME - /// ushort space as / , and it - /// SUBSUMES the low byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a - /// low-id item of . KEEP ids 1-3 stable for the existing resources and - /// reserve new item ids > 3; 0 = none. Entity/prefab refs do NOT live here (blobs don't remap entity - /// refs) — a future companion buffer carries those, exactly like AbilityPrefabElement. - /// - /// The blob is config (baked identically into both worlds, NOT replicated, NOT in SaveData), so growing - /// this struct later (a granted-ability id, a StatModifier-spec array, a slot id for Phase 1/2/3) is a - /// pure re-bake with zero migration: no SaveData version bump, no ghost-hash change, no desync. - /// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a - /// content-only edit. - /// - /// One stat-modifier grant on an equippable item, stored INLINE (NOT a nested BlobArray: a nested - /// BlobArray is a relative-offset pointer that corrupts the moment - /// returns the containing BY VALUE — the same copy hazard the class note warns about). - /// Target 255 = unused. - public struct ItemModSpec - { - /// as a byte; 255 = unused slot. - public byte Target; - /// as a byte. - public byte Op; - /// Magnitude (flat amount or fractional percent). - public float Value; - } - - public struct ItemDefBlob - { - /// Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves). - public ushort ItemId; - - /// Broad category (see ), stored as a byte. - public byte Category; - - /// Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3). - public byte Tier; - - /// Max units that stack in a single inventory slot (1 for non-stacking equipment). - public int StackMax; - - /// Equip slot (see ); 255 = not equippable. - public byte EquipSlot; - - /// Up to INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray. - public ItemModSpec Mod0, Mod1, Mod2, Mod3; - - /// Designer-facing display name (shown in the HUD inventory panel). - public FixedString64Bytes Name; - - /// Number of inline mod slots. - public const int MaxMods = 4; - - /// Indexed access to the inline mod slots (returns a copy — safe, ItemModSpec holds no BlobArray). - public ItemModSpec GetMod(int i) - { - switch (i) - { - case 0: return Mod0; - case 1: return Mod1; - case 2: return Mod2; - default: return Mod3; - } - } - } - - /// - /// Immutable designer-authored item database, baked from ScriptableObjects to a blob asset and shared by - /// every entity (Burst-fast, zero per-instance cost). Looked up by stable - /// — ID-KEYED, never by array index, so inserting a new item never renumbers existing ids. - /// - /// NOTE: the lookup is intentionally NOT a 'readonly' method. A readonly struct method forces a defensive - /// copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its relative-offset - /// pointer, so the array would read as empty. A plain (non-readonly) method accesses the BlobArray in place. - /// Always reach this through 'ref blob.Value' (mirrors ). - /// - public struct ItemDatabaseBlob - { - public BlobArray Items; - - /// Linear lookup by item id (the array is tiny). Returns false if not present. - public bool TryGetItem(ushort id, out ItemDefBlob def) - { - for (int i = 0; i < Items.Length; i++) - { - if (Items[i].ItemId == id) - { - def = Items[i]; - return true; - } - } - def = default; - return false; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta deleted file mode 100644 index 46dde8b2e..000000000 --- a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0ae0cc6bfb8578c42bff8e300078cf2d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs deleted file mode 100644 index e436cddc3..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// One authored PERMANENT meta upgrade: tiered (buy tier owned+1 up to ), priced in Aether - /// with a linear ramp (cost(owned) = BaseCost + owned*CostGrowth), class-gated by - /// (bit0 = Warrior, bit1 = Ranger — resolve via , NEVER a raw 1<<ClassId: - /// the stored ClassId is the normalized FrameKind 2/3). is the stable APPEND-ONLY key - /// persisted in SaveData v6 and keyed into the live StatModifier as Tuning.MetaSourceIdBase + Id. - /// DISTINCT from — boons are run-scoped single-shots; overloading one catalog would - /// blur the two channels (DR-037). = 0xFF means no prerequisite (v1 ships a FLAT catalog - /// — the operator default; trees are an authoring change, not a code change). - /// - public struct MetaUpgradeDefBlob - { - public byte Id; - public byte ClassMask; - public byte Target; // StatTarget as byte - public byte Op; // ModOp as byte - public byte MaxTier; - public float ValuePerTier; - public int BaseCost; - public int CostGrowth; - public byte PrereqId; // 0xFF = none - public byte PrereqTier; - public FixedString64Bytes Name; - public FixedString128Bytes Desc; - } - - /// The baked permanent-upgrade pool (config blob, both worlds, NOT replicated). - public struct MetaUpgradeCatalogBlob - { - public BlobArray Defs; - } - - /// Singleton carrying the baked meta catalog (ONE MetaCatalogAuthoring in the gameplay subscene). - public struct MetaUpgradeCatalog : IComponentData - { - public BlobAssetReference Value; - } - - /// Pure helpers over the meta catalog + the director's record. - public static class MetaMath - { - /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). - public static int FindDef(ref MetaUpgradeCatalogBlob pool, byte id) - { - for (int i = 0; i < pool.Defs.Length; i++) - if (pool.Defs[i].Id == id) return i; - return -1; - } - - /// The owned tier of (class, upgrade) in the record buffer (absent row = 0). - public static byte TierOf(DynamicBuffer record, byte classId, byte upgradeId) - { - for (int i = 0; i < record.Length; i++) - if (record[i].ClassId == classId && record[i].UpgradeId == upgradeId) return record[i].Tier; - return 0; - } - - /// Aether cost of buying tier owned+1 (linear ramp; compute from the CLAMPED owned tier). - public static int CostForTier(in MetaUpgradeDefBlob def, byte ownedClamped) - => def.BaseCost + ownedClamped * def.CostGrowth; - } - - /// - /// The DEFAULT v1 meta table + the blob builder the baker AND EditMode tests share (the BoonCatalogData - /// pattern; an empty designer-row list on the authoring bakes this verbatim). FLAT catalog — every - /// PrereqId = 0xFF (operator default: validate the economy before prereq trees). Ids append-only. - /// Priced for the 15%-Aether node economy (~2-5 Aether per lucky room). - /// - public static class MetaCatalogData - { - public static BlobAssetReference BuildDefault(Allocator allocator = Allocator.Persistent) - { - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var defs = builder.Allocate(ref root.Defs, 8); - int i = 0; - // id, mask(1=Warrior,2=Ranger,3=both), target, op, maxTier, valuePerTier, baseCost, growth, name, desc - defs[i++] = Make(1, 3, StatTarget.MaxHealth, ModOp.Flat, 5, 15f, 10, 5, "Reinforced Frame", "+15 max health per tier"); - defs[i++] = Make(2, 3, StatTarget.Damage, ModOp.PercentAdd, 5, 0.08f, 12, 6, "Sharpened Arsenal", "+8% ability damage per tier"); - defs[i++] = Make(3, 3, StatTarget.CooldownTicks, ModOp.PercentMult, 3, -0.06f, 15, 10, "Swift Recovery", "-6% ability cooldown per tier"); - defs[i++] = Make(4, 3, StatTarget.MoveSpeed, ModOp.PercentAdd, 3, 0.05f, 10, 8, "Fleet Stride", "+5% move speed per tier"); - defs[i++] = Make(5, 1, StatTarget.MeleeDamage, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Warrior's Might", "+10% melee damage per tier"); - defs[i++] = Make(6, 1, StatTarget.MeleeRange, ModOp.PercentAdd, 3, 0.08f, 10, 6, "Warrior's Reach", "+8% melee reach per tier"); - defs[i++] = Make(7, 2, StatTarget.Range, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Ranger's Longshot", "+10% projectile range per tier"); - defs[i++] = Make(8, 2, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 3, 0.10f, 10, 6, "Ranger's Velocity", "+10% projectile speed per tier"); - var blob = builder.CreateBlobAssetReference(allocator); - builder.Dispose(); - return blob; - } - - static MetaUpgradeDefBlob Make(byte id, byte mask, StatTarget target, ModOp op, byte maxTier, - float valuePerTier, int baseCost, int growth, string name, string desc) - { - return new MetaUpgradeDefBlob - { - Id = id, - ClassMask = mask, - Target = (byte)target, - Op = (byte)op, - MaxTier = maxTier, - ValuePerTier = valuePerTier, - BaseCost = baseCost, - CostGrowth = growth, - PrereqId = 0xFF, - PrereqTier = 0, - Name = new FixedString64Bytes(name), - Desc = new FixedString128Bytes(desc), - }; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta deleted file mode 100644 index dfe41e314..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ee5e173c41773dc4189279241bf322b7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs deleted file mode 100644 index 4b14f49f9..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// One owned permanent meta-upgrade tier, keyed by (class, upgrade). The CycleDirector's DynamicBuffer of these is - /// the authoritative per-class meta-progression record. A GLOBAL [GhostField] buffer on the ownerless - /// interpolated director ghost (no OwnerSendType — the party invests together and every client reads it for the - /// shop), mirroring . Persisted to disk (SaveData v6) and re-applied born-correct at - /// player spawn as meta-band s. Bytes only (Burst/serialization safe). Sparse — an - /// absent (class, upgrade) row means tier 0. - /// - [InternalBufferCapacity(24)] - public struct MetaTierState : IBufferElementData - { - /// Owning class id (Warrior/Ranger — the FrameKind anchor). - [GhostField] public byte ClassId; - /// Upgrade id (append-only key into the meta catalog). - [GhostField] public byte UpgradeId; - /// Owned tier (>=1; an absent row = 0). - [GhostField] public byte Tier; - } - - /// - /// Server-only singleton on the CycleDirector: the FIRST-COMMIT latch for the co-op route choice. Written IN-PLACE - /// (immediate SystemAPI.SetComponent, not a deferred ECB) inside RouteSelectSystem's drain loop so two - /// same-tick picks cannot both observe ==0 (the DR-014 atomicity idiom). NOT replicated. - /// - public struct RouteCommand : IComponentData - { - /// 1 once a route has been committed for the current (RunEpoch, layer). - public byte HasPick; - /// The committed option index (into the replicated RouteOpt* set). - public byte OptionIndex; - /// Run epoch the pick is for (stale-reject guard). - public int ForRunEpoch; - /// Layer the pick is for (stale-reject guard). - public int ForLayer; - } - - /// - /// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem - /// sets when a player interacts the portal during RoomExplore; RunDirectorSystem (the sole - /// RunInfo/RunRuntime writer) reads it to advance the run + tear the room down, then clears it. NOT replicated. - /// Added unconditionally at director spawn (like RouteCommand). - /// - public struct PortalCommand : IComponentData - { - /// 1 once a participant has interacted the room-exit portal this RoomExplore. - public byte HasInteract; - } - - - /// - /// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated for - /// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot - /// has the component the bank block reads; restored values are SetComponent'd only inside the save-present block. - /// NOT replicated. - /// - public struct MetaCounters : IComponentData - { - public int RunsCompleted; - public int MaxDepthReached; - } - - /// - /// Server-only tag of a player's class id, added at spawn by GoInGameServerSystem. Lets the meta systems - /// resolve which per-class tier record to seed / spend against (class was previously only an AbilityRef / - /// wire concern). NOT replicated. - /// - public struct PlayerClass : IComponentData - { - public byte ClassId; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta deleted file mode 100644 index 347716e10..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 307374d6819017f4da4bbfe64f11e7c5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs deleted file mode 100644 index 3793f4d45..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server permanent meta-upgrade purchase: keys the baked meta catalog. The - /// TIER is SERVER-COMPUTED (a purchase always buys owned+1) — putting a tier on the wire would invite - /// desync/cheat. Server-validated (RunInfo.Lifecycle==Staging phase gate, class mask, prereq, MaxTier, - /// Aether affordability) with DR-014 in-loop ledger atomicity so two same-tick purchases on barely-enough Aether - /// cannot both pass. UNCONDITIONAL wire type. Declared at Step 3 (wire front-load); consumed by - /// MetaSpendSystem from Step 13. - /// - public struct MetaSpendRequest : IRpcCommand - { - /// Meta-catalog upgrade id (append-only key). - public byte UpgradeId; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta deleted file mode 100644 index 8d9b5a01a..000000000 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 71ddd683487e8704197b8eeddcb2c339 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs deleted file mode 100644 index def18d911..000000000 --- a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// The ONE collector of the permanent-meta save slice (the SaveStructureScan idiom): reads the director's - /// replicated buffer + server-only into the - /// v6 fields. Shared by BOTH save writers — SaveWriteSystem (autosave) AND - /// WorldLauncher.TrySaveFromServer (quit-to-menu) — so the writers can never drift; the quit path - /// silently omitting these fields would WIPE all permanent progression on the most common exit (the meta - /// review's top blocker). Rows are copied VERBATIM (unknown ids round-trip). - /// - public static class MetaSaveScan - { - public static void Collect(EntityManager em, Entity director, - out MetaUpgradeSave[] rows, out int runsCompleted, out int maxDepthReached) - { - rows = System.Array.Empty(); - runsCompleted = 0; - maxDepthReached = 0; - - if (em.HasBuffer(director)) - { - var buf = em.GetBuffer(director, true); - rows = new MetaUpgradeSave[buf.Length]; - for (int i = 0; i < buf.Length; i++) - rows[i] = new MetaUpgradeSave { ClassId = buf[i].ClassId, UpgradeId = buf[i].UpgradeId, Tier = buf[i].Tier }; - } - - if (em.HasComponent(director)) - { - var counters = em.GetComponentData(director); - runsCompleted = counters.RunsCompleted; - maxDepthReached = counters.MaxDepthReached; - } - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta deleted file mode 100644 index 67a201a7f..000000000 --- a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 83b313e94b9f4304993358deca12e6e2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs deleted file mode 100644 index 7dee2a64c..000000000 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Scans a server world for PLAYER-built structures ( + ) - /// into the flat SaveData arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the - /// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved; - /// anything baked into the subscene is the subscene's source of truth, not the save's). Managed (List/array) — - /// runs only on a save, never in the hot loop. - /// - public static class SaveStructureScan - { - public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures) - { - var structs = new List(); - - using var q = em.CreateEntityQuery( - ComponentType.ReadOnly(), - ComponentType.ReadOnly()); - using var entities = q.ToEntityArray(Allocator.Temp); - - for (int k = 0; k < entities.Length; k++) - { - var e = entities[k]; - var ps = em.GetComponentData(e); - - structs.Add(new StructureSave - { - Type = ps.Type, - CellX = ps.Cell.x, - CellZ = ps.Cell.y, - // EB-1: guarded so structures without Health don't crash the autosave path (no try/catch). - HP = em.HasComponent(e) ? em.GetComponentData(e).Current : 0f, - }); - } - - structures = structs.ToArray(); - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta deleted file mode 100644 index e4d58c1b2..000000000 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 47b137b2d90c6154d8c195f8c491f0d8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs deleted file mode 100644 index bfea5e92f..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// A player's private choice-of-3 boon offer for the just-cleared room. OWNER-ONLY replication - /// (): the offer is an observe-only HUD read of the LOCAL player — no - /// prediction, no teammate read — so the traffic-minimal owner-only path is correct (Play-validated at Step 9; - /// the proven fallback is , which for HUD purposes still only surfaces each - /// player's component on the client that owns that ghost). Written by BoonOfferSystem on the RoomReward - /// entry edge (options drawn deterministically from Hash(RunSeed, room, NetworkId)); is - /// cleared by BoonApplySystem on a valid pick and zeroed by the Returning-edge strip. INERT until Step 9 — - /// baked at Step 3 so the player ghost re-bakes exactly ONCE for the whole redesign. - /// - [GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] - public struct BoonOffer : IComponentData - { - /// 1 = awaiting this player's pick. - [GhostField] public byte Pending; - /// Boon catalog id of option 0. - [GhostField] public byte Option0; - /// Boon catalog id of option 1. - [GhostField] public byte Option1; - /// Boon catalog id of option 2. - [GhostField] public byte Option2; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta deleted file mode 100644 index 11c8e323d..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c3c9cb8a1b819fd4b8474206e766076e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs deleted file mode 100644 index e0531c3c3..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a [GhostField], so no ghost-hash impact; - /// it piggybacks the player re-bake). Keys the per-dash "hit once" dedup to - /// (which is TickUtil.NonZero(now) on every dash and cannot be relied - /// upon to reset) rather than to any DashState clear edge: DashTrailDamageSystem clears - /// whenever the current StartTick differs from . Server-only (no rollback) so the - /// accumulator is safe to persist across ticks. - /// - public struct DashTrailState : IComponentData - { - /// The the set currently belongs to. - public uint LastStartTick; - /// Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash). - public FixedList64Bytes Hit; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta b/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta deleted file mode 100644 index b11db19ce..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/DashTrailState.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 248bd87d96cfc5b43b4681a203756c5c \ 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 1e5d773f1..d2e21ab2d 100644 --- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -21,8 +21,6 @@ namespace ProjectM.Simulation public uint Stamp; 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 } /// @@ -52,12 +50,8 @@ namespace ProjectM.Simulation public partial struct MeleeComboSystem : ISystem { ComponentLookup m_KnockbackLookup; - ComponentLookup m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams) - ComponentLookup m_RegionLookup; - BufferLookup m_InvLookup; BufferLookup m_StatModLookup; - ComponentLookup m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup) ComponentLookup m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup) /// Phase 1.7 Detonating Finisher blast radius (planar, tunable). @@ -69,7 +63,7 @@ namespace ProjectM.Simulation /// (G2.2); damage/knockback keep the classic finisher mult. 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, byte bflags, bool hasMods, DynamicBuffer mods) + float2 facingDir, int ownerId, bool hasMods, DynamicBuffer mods) { bool fin = step >= comboLen; float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage); @@ -85,8 +79,7 @@ namespace ProjectM.Simulation Stamp = stamp, KnockUntil = knockUntil, IsFinisher = fin, - Detonate = (bflags & BoonFlag.FinisherDetonate) != 0, - Pull = (bflags & BoonFlag.KnockToPull) != 0, + // Detonate/Pull came from boon flags (deleted 2026-08-07 audit purge). }; } @@ -94,12 +87,8 @@ namespace ProjectM.Simulation public void OnCreate(ref SystemState state) { m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); - m_BossLookup = state.GetComponentLookup(isReadOnly: true); - m_RegionLookup = state.GetComponentLookup(isReadOnly: true); - m_InvLookup = state.GetBufferLookup(isReadOnly: false); m_StatModLookup = state.GetBufferLookup(isReadOnly: true); - m_BoonEffectsLookup = state.GetComponentLookup(isReadOnly: true); m_PendingLookup = state.GetComponentLookup(isReadOnly: false); state.RequireForUpdate(); } @@ -134,7 +123,6 @@ namespace ProjectM.Simulation // when at least one swing actually started — no per-tick enemy gather on idle/client ticks). var cleaves = isServer ? new NativeList(Allocator.Temp) : default; m_StatModLookup.Update(ref state); - m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop) 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 @@ -164,7 +152,6 @@ namespace ProjectM.Simulation 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_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0, m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default)); m_PendingLookup[entity] = default; } @@ -237,7 +224,6 @@ namespace ProjectM.Simulation 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_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0, m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default)); m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep }; } @@ -246,7 +232,6 @@ namespace ProjectM.Simulation 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_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0, m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default)); } } @@ -273,14 +258,8 @@ 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); - foreach (var (po, pe) in SystemAPI.Query>().WithAll().WithEntityAccess()) - meleePlayerByConn[po.ValueRO.NetworkId] = pe; var harvEntity = new NativeList(Allocator.Temp); var harvPos = new NativeList(Allocator.Temp); var harvRemaining = new NativeList(Allocator.Temp); @@ -288,7 +267,6 @@ namespace ProjectM.Simulation var harvPerHit = new NativeList(Allocator.Temp); var harvIsClutter = new NativeList(Allocator.Temp); var harvVariant = new NativeList(Allocator.Temp); - var harvToLedger = new NativeList(Allocator.Temp); foreach (var (hx, node, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) { @@ -301,7 +279,6 @@ namespace ProjectM.Simulation harvPerHit.Add(node.ValueRO.HarvestPerHit); harvIsClutter.Add(false); harvVariant.Add(0); - harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base); } foreach (var (hx, clutter, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) @@ -315,13 +292,11 @@ namespace ProjectM.Simulation harvPerHit.Add(clutter.ValueRO.ScrapPerHit); harvIsClutter.Add(true); harvVariant.Add(clutter.ValueRO.Variant); - harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base); } var harvDestroyed = new NativeArray(harvEntity.Length, Allocator.Temp); m_KnockbackLookup.Update(ref state); - m_BossLookup.Update(ref state); var ecb = new EntityCommandBuffer(Allocator.Temp); for (int s = 0; s < cleaves.Length; s++) @@ -339,29 +314,8 @@ namespace ProjectM.Simulation SourceTick = c.Stamp, }); if (c.KnockSpeed > 0f) - KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target, - 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, - }); + KnockbackUtil.Stamp(ref m_KnockbackLookup, target, + c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil); } } @@ -383,17 +337,12 @@ namespace ProjectM.Simulation // 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]; - // Route by region: Base nodes credit the shared ledger DIRECTLY (the build pool); an - // 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). - Entity meleeHarvester = Entity.Null; - if (meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer)) - meleeHarvester = meleePlayer; + // 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, harvToLedger[i], meleeHarvester, - m_InvLookup, ledger, haveLedger, haveDb, itemDb); + bool deposited = HarvestMath.DepositYield(yieldId, deposit, ledger, haveLedger); if (!deposited) continue; // never consume a YIELDING target for zero credit } @@ -455,8 +404,6 @@ namespace ProjectM.Simulation harvIsClutter.Dispose(); harvVariant.Dispose(); harvDestroyed.Dispose(); - harvToLedger.Dispose(); - meleePlayerByConn.Dispose(); } if (cleaves.IsCreated) diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs deleted file mode 100644 index 2c04cb856..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// The player's expedition ready-check flag — a plain send-to-all [GhostField] byte on the player ghost so - /// EVERY client can render the "N/M READY" staging panel (owner-only would hide teammates' readiness). Written - /// server-only by ReadyToggleSystem from the RPC — honored during Staging - /// AND the Launching countdown (an un-ready during the countdown is the launch-abort escape hatch); cleared for - /// all players by RunDirectorSystem on the Returning edge. The N/M derivation counts live PlayerTag ghosts, - /// which is valid ONLY because the party is co-located at base while Staging (the co-location invariant — N7); - /// ready toggles are refused in every other lifecycle state. - /// - public struct PlayerReady : IComponentData - { - /// 1 = ready to launch; 0 = not ready. - [GhostField] public byte Value; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta deleted file mode 100644 index 045ce581f..000000000 --- a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5423c043f8023a246b4244eb34e5fbdd \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs deleted file mode 100644 index 85afab368..000000000 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - // NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED. - // ExpeditionObjective below is the surviving replicated room-objective readout (live consumers: - // RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it). - - - /// - /// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an - /// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so - /// GhostRelevancy.SetIsIrrelevant never hides it - /// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy - /// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns - /// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]). - /// - public struct ExpeditionObjective : IComponentData - { - /// 0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim). - [GhostField] public byte State; - - /// Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared. - [GhostField] public short Remaining; - } - - /// State constants for (byte, not enum — Burst/serialization). - public static class ExpeditionObjectiveState - { - public const byte Idle = 0; - public const byte Active = 1; - public const byte Cleared = 2; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs.meta b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs.meta deleted file mode 100644 index 81d52d7b1..000000000 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ca714d222c4d2ed48aaaad7bbe6ec8fc \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs deleted file mode 100644 index 51c03e507..000000000 --- a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server: interact with the room-exit portal to leave (advance the run). Client-gated on proximity + - /// the RoomExplore lifecycle (both replicated/derivable client-side); the server honors it ONLY in RoomExplore - /// from an expedition player, setting for RunDirectorSystem (the sole RunInfo writer) - /// to consume. Empty payload. UNCONDITIONAL wire type. - /// - public struct PortalInteractRequest : IRpcCommand { } -} diff --git a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta deleted file mode 100644 index 6ae14ad7b..000000000 --- a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9d527637b76c7a545817f6f388533248 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs deleted file mode 100644 index 210800b0c..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server ready-check toggle — an explicit SET (not a flip), so a duplicated/late RPC is idempotent. - /// UNCONDITIONAL wire type (never #if — the reflection-built RpcCollection hash must match across peers; only - /// send/receive SYSTEMS may be gated). Blittable scalar payload per the project RPC rules. Handled by - /// ReadyToggleSystem (Staging/Launching only). - /// - public struct ReadyToggleRequest : IRpcCommand - { - /// 1 = ready, 0 = not ready. - public byte Ready; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta deleted file mode 100644 index 1c553fb98..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7489e354b7c8d0a46a155fa1d2c22bcc \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs deleted file mode 100644 index 1b5a62ead..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// Pure, deterministic per-room layout math: resolves a map node () into a concrete - /// and scatters points within the room's shape. No RNG state (scatter takes a - /// caller-seeded by ref); no wall-clock — EditMode-unit-testable and save/replay reproducible - /// (mirrors / ). Archetype numbers (per-shape radius, per-type - /// node counts) are const tables here; an authored RoomArchetype blob can later back these when RoomFieldSystem - /// wants designer-tuned variety, without changing this signature's callers. - /// - public static class RoomLayoutMath - { - /// Resolve a map node + its depth into the concrete room spec the server lays out. - public static RoomPlan Plan(in RunMapNode node, int layer, int roomCount) - { - return new RoomPlan - { - RoomType = node.RoomType, - Biome = node.Biome, - ShapeId = node.ShapeId, - Radius = ShapeRadius(node.ShapeId), - NodeCount = BaseNodeCount(node.RoomType), - DifficultyEpoch = DifficultyEpoch(layer, node.RoomType), - }; - } - - /// - /// Depth-based difficulty rung fed to : deeper rooms are harder (layer+1 floor), - /// with Elite/Boss bumps. Lower-bounded at 1. Pure integer. - /// - public static int DifficultyEpoch(int layer, byte roomType) - { - int d = math.max(1, layer + 1); - if (roomType == RoomTypeId.Elite) d += 2; - if (roomType == RoomTypeId.Boss) d += 3; - return d; - } - - /// Base resource-node count per room type (before the run-wide scarcity budget floors it). Reward - /// rooms are dense; combat/elite lean; the Boss room is minimal. - public static int BaseNodeCount(byte roomType) - { - switch (roomType) - { - case RoomTypeId.Reward: return 5; - case RoomTypeId.Combat: return 2; - case RoomTypeId.Elite: return 2; - case RoomTypeId.Boss: return 1; - default: return 2; - } - } - - /// Arena scatter radius (world units) for a shape id. - public static float ShapeRadius(byte shapeId) - { - switch (shapeId) - { - case RoomShapeId.Wide: return 24f; - case RoomShapeId.Long: return 24f; - case RoomShapeId.Cross: return 22f; - case RoomShapeId.Disk: - default: return 18f; - } - } - - /// - /// Deterministic scatter of point of within the room's - /// shape around , using a caller-seeded RNG. Every returned point satisfies - /// for the same shape/center (asserted in tests). Y is preserved from - /// . / are reserved for future - /// even-spacing variants; today the RNG draw is the sole source of position. - /// - public static float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng) - { - float r = ShapeRadius(shapeId); - switch (shapeId) - { - case RoomShapeId.Wide: - { - float x = rng.NextFloat(-r, r); - float z = rng.NextFloat(-r * 0.5f, r * 0.5f); - return new float3(center.x + x, center.y, center.z + z); - } - case RoomShapeId.Long: - { - float x = rng.NextFloat(-r * 0.5f, r * 0.5f); - float z = rng.NextFloat(-r, r); - return new float3(center.x + x, center.y, center.z + z); - } - case RoomShapeId.Cross: - { - bool horiz = rng.NextInt(0, 2) == 0; - float along = rng.NextFloat(-r, r); - float across = rng.NextFloat(-r * 0.25f, r * 0.25f); - return horiz - ? new float3(center.x + along, center.y, center.z + across) - : new float3(center.x + across, center.y, center.z + along); - } - case RoomShapeId.Disk: - default: - { - float ang = rng.NextFloat(0f, math.PI * 2f); - float rad = r * math.sqrt(rng.NextFloat(0f, 1f)); // area-uniform - return new float3(center.x + math.cos(ang) * rad, center.y, center.z + math.sin(ang) * rad); - } - } - } - - /// - /// True iff planar point lies within the shape's footprint around - /// (the exact bound produces). Used to validate scatter and (later) placement. - /// - public static bool ContainsPoint(byte shapeId, float3 center, float3 p) - { - const float eps = 1e-3f; - float r = ShapeRadius(shapeId); - float dx = p.x - center.x; - float dz = p.z - center.z; - switch (shapeId) - { - case RoomShapeId.Wide: - return math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.5f + eps; - case RoomShapeId.Long: - return math.abs(dx) <= r * 0.5f + eps && math.abs(dz) <= r + eps; - case RoomShapeId.Cross: - { - bool horizArm = math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.25f + eps; - bool vertArm = math.abs(dz) <= r + eps && math.abs(dx) <= r * 0.25f + eps; - return horizArm || vertArm; - } - case RoomShapeId.Disk: - default: - return dx * dx + dz * dz <= (r + eps) * (r + eps); - } - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta deleted file mode 100644 index 3d3e66352..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7979eb74587ba004885f89b140b15f00 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs deleted file mode 100644 index f27d63ab8..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace ProjectM.Simulation -{ - /// - /// The resolved, concrete spec for ONE room the party is about to enter — a pure function of its map node - /// () + its depth, produced by . Consumed server-side by - /// the room field/enemy directors to lay out resources + seed the enemy wave; transient (never replicated — - /// the client only needs the small published RunInfo mirror for the HUD). All-value, unmanaged, Burst-safe. - /// - public struct RoomPlan - { - /// (drives node/enemy density + the difficulty bump). - public byte RoomType; - /// (cosmetic, forwarded to the client HUD/atmosphere). - public byte Biome; - /// (the arena footprint scatter uses). - public byte ShapeId; - /// Arena scatter radius (world units) for this shape. - public float Radius; - /// Base number of resource nodes to scatter (before the run-wide scarcity budget floors it). - public int NodeCount; - /// Depth-based difficulty rung fed to ZoneEnemyMath (higher = harder; Elite/Boss bump it). - public int DifficultyEpoch; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta deleted file mode 100644 index 22a7b7b08..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fc7dbc4b8c341e746b1cbe11f3a9ad86 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs deleted file mode 100644 index 30e7697a4..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Stamps a runtime-spawned expedition ghost as belonging to ONE room of the current run (nodes, clutter, zone - /// enemies — everything the room's directors instantiate). Server-only, NOT a [GhostField] (clients never - /// see rooms, only relevancy-scoped ghosts). Teardown of room i filters on — the - /// hard-learned DR-031/DR-040 lesson that a shared-tag global cull wipes the OTHER room the moment two rooms - /// transiently coexist (the ping-pong sub-slot handoff). = CurrentRoom & 0xFF. - /// - public struct RoomTag : IComponentData - { - /// The 0-based room index this entity belongs to (low byte). - public byte Room; - } - - /// - /// The ONE way a room's contents die: a -filtered destroy. Type-agnostic — every room-scoped - /// ghost carries the tag, so one query covers nodes/clutter/enemies with no per-type sweep and no double-destroy - /// (each entity is visited exactly once). Callers pass their cached all- query + an ECB - /// (structural changes stay batched). Pure/static so EditMode pins the cross-room-wipe regression directly. - /// - public static class RoomTeardown - { - /// Queue destruction of every entity stamped .Room == . - public static int DestroyRoom(EntityQuery allRoomTagged, EntityCommandBuffer ecb, byte room) - { - var entities = allRoomTagged.ToEntityArray(Allocator.Temp); - var tags = allRoomTagged.ToComponentDataArray(Allocator.Temp); - int destroyed = 0; - for (int i = 0; i < entities.Length; i++) - { - if (tags[i].Room != room) continue; - ecb.DestroyEntity(entities[i]); - destroyed++; - } - entities.Dispose(); - tags.Dispose(); - return destroyed; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta deleted file mode 100644 index b36675319..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7fd0f5749da96e14db98fc4e6ada65b0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs deleted file mode 100644 index 0e5d4eea0..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client → server route choice at a RouteSelect gate. indexes the REPLICATED - /// RunInfo.RouteOpt* option set (never a raw map column — the server re-validates against its own - /// NextMask, so a divergent client can only send an index the server rejects). - /// / stale-reject a pick that arrives after the party already - /// advanced. First ACCEPTED commit wins (the in-place RouteCommand latch — DR-014 atomicity). - /// UNCONDITIONAL wire type, blittable scalars only. Declared at Step 3 (wire front-load, one RpcCollection hash - /// change for the whole redesign); consumed by RouteSelectSystem from Step 8. - /// - public struct RouteSelectRequest : IRpcCommand - { - /// Index into the replicated RouteOpt* set (0..RouteOptionCount-1). - public byte OptionIndex; - /// RE-MEANED (Step-8 review, zero wire churn): carries (int)RunInfo.RunSeed — the - /// replicated, per-run-unique, never-zero run-identity token — NOT the server-only RunEpoch (which a client - /// cannot know). The server accepts iff (uint)ForRunEpoch == RunRuntime.RunSeed: the full cross-run - /// stale-reject at zero RpcCollection-hash cost (re-mean bytes, don't rename). - public int ForRunEpoch; - /// The layer this pick was made for — RunInfo.CurrentRoom VERBATIM (during a gate that is - /// still the just-CLEARED layer; never +1 — the server compares the same un-incremented value). - public int ForLayer; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta deleted file mode 100644 index d1c783443..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 24000ef6fe52691408d4247341cbb189 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs deleted file mode 100644 index 343aeb0dd..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Lifecycle states for a co-op expedition run (). A byte, never an enum - /// (Burst/serialization safe), APPEND-ONLY. is appended after the original four so no - /// value is re-meaned. The party is at the base hub in ; a discrete run spans - /// (loop) → - /// . - /// - public static class RunLifecycle - { - /// Party in the base hub; ready-check active; no expedition ghosts exist. - public const byte Staging = 0; - /// All-ready launch transient: seed chosen, party teleporting into room 0. - public const byte Launching = 1; - /// Active room populated; party fighting/looting. - public const byte InRoom = 2; - /// Room cleared; per-player boon offers pending; room torn down. - public const byte RoomReward = 3; - /// Run ended (boss cleared / party wiped / all left): teleport home, bank, → Staging. - public const byte Returning = 4; - /// Boons picked; party choosing the next branch (no room materialized — the teardown gap). - public const byte RouteSelect = 5; - /// DR-046: room cleared + boon picked, but the room + resource NODES persist and a portal is up. - /// Party loots; interacting the portal (or a soft-timeout) tears the room down + advances (RouteSelect, or - /// Returning if the boss fell). Append-only byte value — no ghost re-mean. - public const byte RoomExplore = 6; - - } - - /// - /// The REPLICATED run-lifecycle summary the whole party observes — a server-decided, client-observed FSM on the - /// GLOBAL untagged CycleDirector ghost (so it is relevant cross-region for free, like / - /// /). SOLE writer: RunDirectorSystem. Distinct from - /// (that stays the BASE Calm↔Siege posture for retaliation/final sieges). - /// - /// Fields split three ways: the lifecycle/room readout (HUD "Room i/N", biome cross-fade), the branching-map - /// wire ( so the client regenerates the map for DISPLAY, + and the - /// authoritative reachable RouteOpt* the clickable options bind to), and a two-field mirror of the - /// persisted meta counters for the HUD. All integers/bytes → replicate exact (no quantization). Adding this - /// [GhostField] component re-hashes the runtime-spawned director ghost (server + client bake the same - /// prefab → hash matches), exactly like /. - /// - public struct RunInfo : IComponentData - { - // ---- lifecycle + room readout ---- - /// . - [GhostField] public byte Lifecycle; - /// 0-based depth of the active room (HUD "Room CurrentRoom+1 / RoomCount"). - [GhostField] public int CurrentRoom; - /// Total rooms this run (== map layer count, seed-varied in [6,10]). - [GhostField] public int RoomCount; - /// of the active room. - [GhostField] public byte CurrentRoomType; - /// of the active room (client atmosphere cross-fade). - [GhostField] public byte CurrentBiome; - /// Server tick the launch countdown elapses (0 = none). Via ; compared with IsNewerThan. - [GhostField] public uint LaunchTick; - - // ---- branching map wire ---- - /// The run seed — clients regenerate the map layout for DISPLAY via (no gameplay authority). - [GhostField] public uint RunSeed; - /// The party's current column in the active layer. - [GhostField] public byte CurrentCol; - /// Number of reachable next-room options (0 unless ). - [GhostField] public byte RouteOptionCount; - /// Reachable next-layer column for option 0 (authoritative — the clickable button binds to this, not the regen). - [GhostField] public byte RouteOpt0Col; - [GhostField] public byte RouteOpt1Col; - [GhostField] public byte RouteOpt2Col; - /// of option 0 (so the HUD labels the choice). - [GhostField] public byte RouteOpt0Type; - [GhostField] public byte RouteOpt1Type; - [GhostField] public byte RouteOpt2Type; - - // ---- persisted-meta HUD mirror ---- - /// Runs completed (boss-cleared), mirrored for the HUD from the persisted meta counters. - [GhostField] public int RunsCompleted; - /// Deepest room reached across runs, mirrored for the HUD. - [GhostField] public int MaxDepthReached; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta deleted file mode 100644 index 0a3d38575..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 71c6d427dd2c6aa4789f0ab94833ac71 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunMap.cs b/Assets/_Project/Scripts/Simulation/World/RunMap.cs deleted file mode 100644 index c04101b6f..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunMap.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using Unity.Collections; - -namespace ProjectM.Simulation -{ - /// - /// Room-TYPE ids for a run-map node. A byte, never a C# enum — kept Burst-safe, serialization/replay-stable, - /// and APPEND-ONLY (a persisted meta / replay reproducibility depends on these values never being re-meaned). - /// - public static class RoomTypeId - { - public const byte Combat = 0; - public const byte Elite = 1; - public const byte Reward = 2; - public const byte Boss = 3; - public const byte Count = 4; - } - - /// - /// Room SHAPE ids — the arena footprint places nodes/enemies within. - /// A byte (append-only). Resolved to a concrete radius/footprint by . - /// - public static class RoomShapeId - { - public const byte Disk = 0; // circular arena (area-uniform scatter) - public const byte Wide = 1; // rectangle, wider on X - public const byte Long = 2; // rectangle, longer on Z - public const byte Cross = 3; // plus/cross of two bars - public const byte Count = 4; - } - - /// - /// Cosmetic BIOME ids — resolved to atmosphere/fog/tint by the client presentation layer (WorldAtmosphereSystem) - /// per room. A byte (append-only); purely visual, no gameplay authority. - /// - public static class RoomBiomeId - { - public const byte Meadow = 0; - public const byte Arid = 1; - public const byte Cavern = 2; - public const byte Blight = 3; - public const byte Count = 4; - } - - /// - /// One node in the branching run-map DAG (Slay-the-Spire style). 4 bytes, unmanaged. is a - /// bit set: bit j ⇒ this node can advance to column j of the NEXT layer (j < RunMap.MaxWidth). - /// A node with == 0 is a terminal (the single Boss node). Generated purely from the run seed - /// by , so it is identical on server + client (client regenerates for display). - /// - public struct RunMapNode : IEquatable - { - /// . - public byte RoomType; - /// (cosmetic). - public byte Biome; - /// . - public byte ShapeId; - /// Reachable next-layer columns: bit j ⇒ column j of the next layer. 0 = terminal (Boss). - public byte NextMask; - - public bool Equals(RunMapNode o) => - RoomType == o.RoomType && Biome == o.Biome && ShapeId == o.ShapeId && NextMask == o.NextMask; - public override bool Equals(object o) => o is RunMapNode n && Equals(n); - public override int GetHashCode() => RoomType | (Biome << 8) | (ShapeId << 16) | (NextMask << 24); - } - - /// - /// A generated branching run map: a layered DAG the party traverses one node per layer. TRANSIENT — regenerated - /// from the run seed via and NEVER a ghost buffer / never persisted (only the - /// seed + the party's current column ride the wire). Fixed stride of per layer, so the - /// stable node key nodeId = layer*MaxWidth + col resolves the SAME room regardless of the path taken — - /// which keeps per-room content (layout, boons) deterministic. Bounded to so it lives in a - /// (30 × 4 B = 120 B). - /// - public struct RunMap - { - /// Max layers (run length is seed-varied within [6, ]). - public const int MaxLayers = 10; - /// Max nodes per layer (branch width 1–3). - public const int MaxWidth = 3; - /// Node-buffer capacity (fixed stride): × . - public const int MaxNodes = MaxLayers * MaxWidth; - - /// Nodes, fixed stride per layer (LayerCount*MaxWidth entries; columns - /// ≥ are absent/unused). - public FixedList512Bytes Nodes; - /// Per-layer branch width ( entries, each in [1, ]). - public FixedList64Bytes LayerWidths; - /// Number of layers this run (== room count, in [6, ]). - public byte LayerCount; - - /// Stable node key for a (layer, col) — fixed stride, so the same key is the same room on any path. - public static int NodeId(int layer, int col) => layer * MaxWidth + col; - /// Branch width of a layer. - public int Width(int layer) => LayerWidths[layer]; - /// Node at (layer, col). - public RunMapNode Node(int layer, int col) => Nodes[NodeId(layer, col)]; - /// Node by stable id. - public RunMapNode NodeAt(int nodeId) => Nodes[nodeId]; - /// Layer of a node id. - public int LayerOf(int nodeId) => nodeId / MaxWidth; - /// Column of a node id. - public int ColOf(int nodeId) => nodeId % MaxWidth; - /// The single Boss node id (last layer, column 0). - public int BossNodeId => NodeId(LayerCount - 1, 0); - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta deleted file mode 100644 index 463ad7e9d..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 59ab777c8e8ab794895a7236f5212758 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs deleted file mode 100644 index aa26c8a25..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs +++ /dev/null @@ -1,225 +0,0 @@ -using Unity.Collections; -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// Pure, deterministic generator for the branching run-map DAG — no RNG state, no wall-clock, INTEGER-HASH ONLY - /// (no , whose draw order is fragile across the multi-pass edge build and - /// which the client would have to replay bit-identically). A run map is therefore a pure function of the run seed, - /// so server + client regenerate the SAME graph — the server keeps gameplay authority (the party's column + the - /// reachable options ride the wire), the client regenerates only to DRAW the map. Mirrors the - /// pure-math discipline. - /// - /// Structure: layer 0 = a single Combat landing node; interior layers width 2–3, weighted-typed - /// (Combat 60 / Reward 25 / Elite 15, with an all-Reward-layer guard); the second-to-last layer is an all-Elite - /// gate (so every start→boss path passes ≥1 Elite); the last layer = the single Boss terminal. Edges: a primary - /// pass (every source gets ≥1 proportional out-edge, jittered, sometimes widened) + a coverage pass (every target - /// gets ≥1 in-edge), which together guarantee full reachability from the root and exactly one terminal. - /// - public static class RunMapMath - { - // ---- deterministic integer hashing (order-independent combine + a final avalanche) ---- - - static uint Mix(uint h) - { - h ^= h >> 16; h *= 0x7feb352du; - h ^= h >> 15; h *= 0x846ca68bu; - h ^= h >> 16; - return h; - } - - static uint Combine(uint h, uint v) - { - // boost-style hash_combine - h ^= v + 0x9e3779b9u + (h << 6) + (h >> 2); - return h; - } - - /// Deterministic hash of a salt tuple (integer-only, well-mixed, never dependent on draw order). - public static uint Hash(uint a) => Mix(Combine(0x811c9dc5u, a)); - public static uint Hash(uint a, uint b) => Mix(Combine(Combine(0x811c9dc5u, a), b)); - public static uint Hash(uint a, uint b, uint c) => Mix(Combine(Combine(Combine(0x811c9dc5u, a), b), c)); - public static uint Hash(uint a, uint b, uint c, uint d) => - Mix(Combine(Combine(Combine(Combine(0x811c9dc5u, a), b), c), d)); - - /// - /// Generate the branching run map for . Deterministic + identical on both worlds. - /// - public static RunMap Generate(uint runSeed) - { - uint s = math.max(1u, runSeed); - int L = 6 + (int)(Hash(s, 0x1Au) % 5u); // run length in [6,10] - - var map = new RunMap { LayerCount = (byte)L }; - - // Per-layer branch widths: single landing + single boss, interior 2–3. - map.LayerWidths = new FixedList64Bytes(); - for (int layer = 0; layer < L; layer++) - { - byte w = (layer == 0 || layer == L - 1) - ? (byte)1 - : (byte)(2 + (int)(Hash(s, (uint)layer, 0x11u) % 2u)); // 2 or 3 - map.LayerWidths.Add(w); - } - - // Nodes: fixed stride MaxWidth per layer (absent columns left default). - map.Nodes = new FixedList512Bytes(); - int slots = L * RunMap.MaxWidth; - for (int i = 0; i < slots; i++) map.Nodes.Add(default); - - // Types / biome / shape. - for (int layer = 0; layer < L; layer++) - { - int w = map.LayerWidths[layer]; - byte layerBiome = (byte)(Hash(s, (uint)layer, 0xB1u) % RoomBiomeId.Count); - bool anyNonReward = false; - for (int col = 0; col < w; col++) - { - byte type = PickType(s, layer, col, L); - if (type != RoomTypeId.Reward) anyNonReward = true; - byte shape = (byte)(Hash(s, (uint)layer, (uint)col, 0x5Au) % RoomShapeId.Count); - map.Nodes[RunMap.NodeId(layer, col)] = new RunMapNode - { - RoomType = type, - Biome = layerBiome, - ShapeId = shape, - NextMask = 0, - }; - } - // Guard: never an entire interior layer of only Reward rooms → force column 0 to Combat. - if (!anyNonReward && w > 0) - { - int id0 = RunMap.NodeId(layer, 0); - var n = map.Nodes[id0]; - n.RoomType = RoomTypeId.Combat; - map.Nodes[id0] = n; - } - } - - BuildEdges(ref map, s); - return map; - } - - static byte PickType(uint s, int layer, int col, int L) - { - if (layer == 0) return RoomTypeId.Combat; // guaranteed landing room - if (layer == L - 1) return RoomTypeId.Boss; // single terminal - if (layer == L - 2) return RoomTypeId.Elite; // all-Elite gate (≥1 Elite on every path) - uint r = Hash(s, (uint)layer, (uint)col, 0xC0u) % 100u; // Combat 60 / Reward 25 / Elite 15 - if (r < 60u) return RoomTypeId.Combat; - if (r < 85u) return RoomTypeId.Reward; - return RoomTypeId.Elite; - } - - static void BuildEdges(ref RunMap map, uint s) - { - int L = map.LayerCount; - for (int l = 0; l < L - 1; l++) - { - int w = map.LayerWidths[l]; - int wn = map.LayerWidths[l + 1]; - - // Primary: every source gets a proportional out-edge (± jitter), sometimes widened to a neighbor. - for (int c = 0; c < w; c++) - { - int t = ProportionalCol(c, w, wn); - int jitter = (int)(Hash(s, (uint)l, (uint)c, 0xEDu) % 3u) - 1; // -1, 0, +1 - t = math.clamp(t + jitter, 0, wn - 1); - SetEdge(ref map, l, c, t); - - if (wn > 1 && Hash(s, (uint)l, (uint)c, 0x2Bu) % 100u < 35u) - { - int dir = (Hash(s, (uint)l, (uint)c, 0x2Cu) % 2u) == 0u ? -1 : 1; - int t2 = math.clamp(t + dir, 0, wn - 1); - SetEdge(ref map, l, c, t2); - } - } - - // Coverage: every target in the next layer must have ≥1 in-edge (forces convergence on the Boss). - for (int tcol = 0; tcol < wn; tcol++) - { - if (!HasInEdge(ref map, l, tcol)) - { - int src = ProportionalCol(tcol, wn, w); - SetEdge(ref map, l, src, tcol); - } - } - } - } - - static int ProportionalCol(int from, int fromWidth, int toWidth) - { - if (fromWidth <= 1 || toWidth <= 1) return toWidth / 2; - return (int)math.round((float)from * (toWidth - 1) / (fromWidth - 1)); - } - - static void SetEdge(ref RunMap map, int layer, int col, int targetCol) - { - int id = RunMap.NodeId(layer, col); - var n = map.Nodes[id]; - n.NextMask |= (byte)(1 << targetCol); - map.Nodes[id] = n; - } - - static bool HasInEdge(ref RunMap map, int layer, int targetCol) - { - int w = map.LayerWidths[layer]; - byte bit = (byte)(1 << targetCol); - for (int c = 0; c < w; c++) - if ((map.Nodes[RunMap.NodeId(layer, c)].NextMask & bit) != 0) return true; - return false; - } - - /// - /// The columns of the NEXT layer reachable from node (, ). - /// Empty for the Boss/last layer. This is the authoritative set the route-choice offer is drawn from. - /// - public static int ReachableOptions(in RunMap map, int layer, int col, out FixedList32Bytes cols) - { - cols = new FixedList32Bytes(); - if (layer < 0 || layer >= map.LayerCount - 1) return 0; - byte mask = map.Node(layer, col).NextMask; - int wn = map.Width(layer + 1); - for (int j = 0; j < wn; j++) - if ((mask & (1 << j)) != 0) cols.Add((byte)j); - return cols.Length; - } - - /// - /// True iff every PRESENT node is reachable from the root (0,0) via the edges (BFS). Used to assert the - /// generator never strands a node or the Boss. O(nodes). - /// - public static bool AllNodesReachable(in RunMap map) - { - var visited = new FixedList128Bytes(); - for (int i = 0; i < RunMap.MaxNodes; i++) visited.Add(0); - - var stack = new FixedList128Bytes(); - int root = RunMap.NodeId(0, 0); - visited[root] = 1; - stack.Add((byte)root); - - while (stack.Length > 0) - { - int id = stack[stack.Length - 1]; - stack.RemoveAt(stack.Length - 1); - int layer = map.LayerOf(id); - if (layer >= map.LayerCount - 1) continue; - byte mask = map.NodeAt(id).NextMask; - int wn = map.Width(layer + 1); - for (int j = 0; j < wn; j++) - { - if ((mask & (1 << j)) == 0) continue; - int nid = RunMap.NodeId(layer + 1, j); - if (visited[nid] == 0) { visited[nid] = 1; stack.Add((byte)nid); } - } - } - - for (int layer = 0; layer < map.LayerCount; layer++) - for (int col = 0; col < map.Width(layer); col++) - if (visited[RunMap.NodeId(layer, col)] == 0) return false; - return true; - } - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta deleted file mode 100644 index eb7f2f9b6..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6f2e10313b5eeac468bef027c5c7be62 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs deleted file mode 100644 index 7953ac585..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// SERVER-ONLY roster tag for the players conscripted into the CURRENT run — stamped on every connected - /// player at the launch edge (Launching → room 0), removed on the Returning edge. Room advances teleport - /// ONLY participants: a dead-respawned member (back at base) is re-conscripted on the next advance (the - /// operator-locked default), while a mid-run late JOINER — who never readied — stays safely at base until - /// the next Staging (spec §2.2 closed-party rule; post-impl review, confirmed medium). NOT a [GhostField]; - /// disconnect cleanup is free (the tag dies with the player ghost's LinkedEntityGroup despawn). - /// - public struct RunParticipant : IComponentData - { - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta deleted file mode 100644 index 69953e4dd..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 92795e88279d04e42af70f8ec1032cc7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs deleted file mode 100644 index b1d03f7ca..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace ProjectM.Simulation -{ - /// - /// Server-only working state for the run FSM — lives on the CycleDirector beside but is - /// NOT replicated (adding fields here never re-bakes the ghost). Owned/written by RunDirectorSystem. - /// Determinism: = max(1, Hash(, )) — monotonic - /// int, never a tick, equality-compared. Tick sentinels (/) - /// route through and compare via NetworkTick.IsNewerThan (never raw uint). - /// - public struct RunRuntime : Unity.Entities.IComponentData - { - // ---- run identity / seed ---- - /// Working copy of the run seed (mirrored to the replicated ). - public uint RunSeed; - /// Monotonic run counter; bumped on the Staging→Launching edge so each run reseeds. Equality-compared. - public int RunEpoch; - /// Per-playthrough salt folded into for cross-session map variety (seeded at spawn, non-tick). - public uint HostSalt; - - // ---- room traversal ---- - /// Monotonic room-seed counter; bumped per room advance so the field/enemy directors reseed. Equality-compared. - public int RoomEpoch; - /// Which of the two ping-pong sub-arena slots the active room occupies (CurrentRoom & 1). - public byte ActiveSubSlot; - /// The active room's stable map node id (single plan authority — field/enemy directors read this, never re-derive). - public int CurrentNodeId; - /// The active room's column (mirrors ). - public byte CurrentCol; - /// The active room's (single plan authority). - public byte CurrentRoomType; - - // ---- scarcity / banking latches ---- - /// Run-wide remaining resource-node allotment (floors each room's scatter; decrements per node) → true scarcity. - public int NodeBudgetRemaining; - /// The the terminal bank last fired for — equality latch so a multi-tick Returning banks once. - public int LastBankedRunEpoch; - /// 1 iff the run ended by a genuine BOSS clear (gates the win-meter/RunsCompleted credit; 0 on abort/wipe). - public byte LastTerminalCleared; - /// Rooms actually CLEARED this run (bumped on each InRoom→RoomReward edge; reset at launch) — the - /// honest depth the terminal bank records into MaxDepthReached (never the planned RoomCount — D-F3). - public int RoomsClearedThisRun; - - // ---- ready / grace ---- - /// Previous-tick all-ready state (rising-edge latch for the Staging→Launching launch). - public byte WasAllReady; - /// Server tick the RoomReward boon-pick grace elapses (NonZero; IsNewerThan-compared). - public uint RewardGraceTick; - /// Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared). - public uint RouteGraceTick; - - /// DR-046: RoomExplore soft-timeout (NonZero; auto-advance if nobody interacts the portal), so the - /// loot window can never softlock. Set on entering RoomExplore, compared via NetworkTick.IsNewerThan. - public uint ExploreGraceTick; - - - // ---- boons ---- - /// Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run. - public uint BoonPickCounter; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta deleted file mode 100644 index a24449b30..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6936c1ec155a69a45a957a2f2dac1c3f \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs deleted file mode 100644 index ce7f6388c..000000000 --- a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; -using System.Collections.Generic; - -namespace ProjectM.Tests -{ - /// - /// Pins the two-channel boon lifecycle (Phase 1.7 table). : a valid STAT pick appends - /// exactly ONE boon-band and clears Pending; a MECHANIC-CHANGER pick mutates - /// (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected; - /// 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%). - /// - public class BoonApplyTests - { - const uint T0 = 3000; - readonly List _worlds = new(); - - [TearDown] - public void Cleanup() - { - foreach (var w in _worlds) if (w.IsCreated) w.Dispose(); - _worlds.Clear(); - } - - static (World world, SimulationSystemGroup group, Entity dir, Entity catalog) MakeWorld(byte lifecycle) - { - var world = new World("BoonApplyTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); - em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = 1 }); - em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RoomEpoch = 2, RewardGraceTick = T0 + 1800 }); - - var catalog = em.CreateEntity(typeof(BoonCatalog)); - em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() }); - return (world, group, dir, catalog); - } - - // 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), - typeof(BoonEffects)); - em.AddBuffer(e); - em.SetComponentData(e, new GhostOwner { NetworkId = netId }); - em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition }); - em.SetComponentData(e, new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 }); - return e; - } - - static void SendPick(EntityManager em, int netId, byte index) - { - var conn = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(conn, new NetworkId { Value = netId }); - var req = em.CreateEntity(typeof(BoonPickRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(req, new BoonPickRequest { Index = index }); - em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int BoonRows(EntityManager em, Entity player) - { - var mods = em.GetBuffer(player); - int n = 0; - for (int i = 0; i < mods.Length; i++) - if (mods[i].SourceId >= Tuning.BoonSourceIdBase - && mods[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan) n++; - return n; - } - - [Test] - public void ValidStatPick_AppendsBoonBandRow_AndClearsPending() - { - var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); - using (world) - { - var em = world.EntityManager; - var player = MakePicker(em, 1); - SendPick(em, 1, index: 1); // Option1 = id 11 (Fleet Foot, MoveSpeed +18%) - - group.Update(); - - Assert.AreEqual(1, BoonRows(em, player), "exactly one boon-band row appended"); - var mods = em.GetBuffer(player); - Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target"); - Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op); - Assert.AreEqual(0.18f, mods[0].Value, 1e-4f); - Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); - Assert.AreEqual(1u, em.GetComponentData(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(player).Pierce, "Pierce incremented"); - Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); - Assert.AreEqual(0u, em.GetComponentData(dir).BoonPickCounter, "no band row → counter unchanged"); - } - } - - [Test] - public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean() - { - // Not pending. - var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward); - _worlds.Add(w1); - var p1 = MakePicker(w1.EntityManager, 1); - w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 9 }); - SendPick(w1.EntityManager, 1, 0); - g1.Update(); - Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected"); - - // Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4). - var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning); - _worlds.Add(w2); - var p2 = MakePicker(w2.EntityManager, 1); - SendPick(w2.EntityManager, 1, 0); - g2.Update(); - Assert.AreEqual(0, BoonRows(w2.EntityManager, p2), "closed-lifecycle pick rejected"); - using (var q = w2.EntityManager.CreateEntityQuery(typeof(BoonPickRequest))) - Assert.AreEqual(0, q.CalculateEntityCount(), "request still consumed"); - } - - [Test] - public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers() - { - var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); - using (world) - { - var em = world.EntityManager; - var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 MaxHealth) - var run = em.GetComponentData(dir); - run.RewardGraceTick = T0 - 10; // already elapsed - em.SetComponentData(dir, run); - - group.Update(); - - Assert.AreEqual(1, BoonRows(em, afk), "AFK player auto-dealt Option0"); - var mods = em.GetBuffer(afk); - Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target); - Assert.AreEqual(0, em.GetComponentData(afk).Pending, "gate released"); - } - } - - [Test] - public void ReturningStrip_KillsBoonBand_ZeroesEffects_SparesClassMetaEquip() - { - // 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"); - using (world) - { - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); - em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Returning, CurrentRoom = 3, RoomCount = 8 }); - em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 }); - - var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer), - typeof(RegionTag), typeof(LocalTransform), typeof(BoonEffects)); - em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); - em.SetComponentData(player, LocalTransform.Identity); - em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 }); - em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy }); - var mods = em.AddBuffer(player); - mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // 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 = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band) - mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip - var timed = em.AddBuffer(player); - timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 }); - - group.Update(); // Returning: strip + bank + home -> Staging - - var after = em.GetBuffer(player); - Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, three permanent bands survive"); - for (int i = 0; i < after.Length; i++) - Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase - && after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor"); - Assert.AreEqual(0, em.GetBuffer(player).Length, "Frenzy timed row stripped"); - Assert.AreEqual(default(BoonEffects), em.GetComponentData(player), "mechanic-changer effects zeroed"); - Assert.AreEqual(0, em.GetComponentData(player).Pending, "straggler offer zeroed"); - Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta deleted file mode 100644 index 188658e6d..000000000 --- a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f703eba535e33a3489b833c59cbd3803 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs deleted file mode 100644 index 023e2697e..000000000 --- a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Pins the boon pool math (: deterministic, 3 distinct, class-filtered, - /// weight-0 excluded) and (one deal per RoomEpoch; expedition players only; - /// owner-seeded per player so co-op offers differ). - /// - public class BoonOfferTests - { - [Test] - public void PickBoons_Deterministic_Distinct_ClassFiltered() - { - 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 += 7) - { - 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.AreNotEqual(a0, a1, "distinct"); - Assert.AreNotEqual(a1, a2, "distinct"); - Assert.AreNotEqual(a0, a2, "distinct"); - - // Deterministic re-draw. - BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte b0, out byte b1, out byte b2); - Assert.AreEqual(a0, b0); - Assert.AreEqual(a1, b1); - Assert.AreEqual(a2, b2); - - // Every option is class-legal. - byte bit = BoonMath.MaskFor(classId); - foreach (var id in new[] { a0, a1, a2 }) - { - int idx = BoonMath.FindDef(ref pool, id); - Assert.GreaterOrEqual(idx, 0, "offered id exists"); - Assert.AreNotEqual(0, pool.Defs[idx].ClassMask & bit, - $"boon {id} offered to class {classId} must pass its ClassMask"); - } - } - } - blob.Dispose(); - } - - [Test] - public void OfferSystem_DealsOncePerRoom_ExpeditionOnly_PerPlayerSeeds() - { - var world = new World("BoonOfferTest"); - using (world) - { - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); - em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.RoomReward, CurrentRoom = 1 }); - em.SetComponentData(dir, new RunRuntime { RunSeed = 4242u, RoomEpoch = 2 }); - - var catalog = em.CreateEntity(typeof(BoonCatalog)); - em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() }); - - Entity MakePlayer(int netId, byte region, byte classId) - { - var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), - typeof(RegionTag), typeof(PlayerClass), typeof(BoonEffects)); - em.SetComponentData(e, new GhostOwner { NetworkId = netId }); - em.SetComponentData(e, new RegionTag { Region = region }); - em.SetComponentData(e, new PlayerClass { ClassId = classId }); - return e; - } - var out1 = MakePlayer(1, RegionId.Expedition, 0); - var out2 = MakePlayer(2, RegionId.Expedition, 1); - var home = MakePlayer(3, RegionId.Base, 0); - - group.Update(); // one-shot state attach - group.Update(); // deal - - var offer1 = em.GetComponentData(out1); - var offer2 = em.GetComponentData(out2); - Assert.AreEqual(1, offer1.Pending, "expedition player 1 dealt"); - Assert.AreEqual(1, offer2.Pending, "expedition player 2 dealt"); - Assert.AreEqual(0, em.GetComponentData(home).Pending, "home player dealt NOTHING"); - bool differ = offer1.Option0 != offer2.Option0 || offer1.Option1 != offer2.Option1 - || offer1.Option2 != offer2.Option2; - Assert.IsTrue(differ, "per-player seeds -> co-op offers differ (seed folds NetworkId)"); - - // Same epoch -> no re-deal (clear one offer and confirm it stays cleared). - em.SetComponentData(out1, default(BoonOffer)); - group.Update(); - Assert.AreEqual(0, em.GetComponentData(out1).Pending, "one deal per RoomEpoch (latch)"); - - } - } - - - 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(); - } -} -} diff --git a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta deleted file mode 100644 index fea7a0fcd..000000000 --- a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1e10416fe4cd405479a526eddd91bc1f \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs deleted file mode 100644 index 7677c1dfe..000000000 --- a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode coverage for the server-only BossAISystem (the sole boss mover/attacker, previously - /// Play-only). Exercises phase gating, expedition-only targeting, the telegraphed radial-slam AoE + its radius - /// boundary, the B4 lunge-vs-slam windup disambiguation (a naive shared-windup reuse would slam on a lunge - /// elapse), and knockback immunity. Values are pinned to Tuning.Boss* so the tests track tuning, not literals. - /// No PhysicsWorldSingleton is created -> sweep=false -> the boss moves/lands unswept (headless-safe). - /// - public class BossAISystemTests - { - static Entity MakeBoss(EntityManager em, float3 pos, float cur, float max, - byte pending = 0, uint windup = 0, uint slamReady = 0, uint lungeReady = 0, uint knockUntil = 0) - { - var e = em.CreateEntity(typeof(EnemyTag), typeof(BossState), typeof(LocalTransform), typeof(EnemyStats), - typeof(Health), typeof(AttackWindup), typeof(KnockbackState), typeof(LungeState)); - em.SetComponentData(e, LocalTransform.FromPosition(pos)); - em.SetComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 2f, AttackDamage = 10f, AttackCooldownTicks = 30 }); - em.SetComponentData(e, new Health { Current = cur, Max = max }); - em.SetComponentData(e, new BossState { Phase = 1, PendingAttack = pending, SlamReadyTick = slamReady, LungeReadyTick = lungeReady }); - em.SetComponentData(e, new AttackWindup { WindUpUntilTick = windup }); - em.SetComponentData(e, new KnockbackState { UntilTick = knockUntil }); - return e; - } - - static Entity MakePlayer(EntityManager em, float3 pos, byte region = RegionId.Expedition, float hp = 100f) - { - var e = em.CreateEntity(typeof(PlayerTag), typeof(RegionTag), typeof(LocalTransform), typeof(Health), typeof(DamageEvent)); - em.SetComponentData(e, new RegionTag { Region = region }); - em.SetComponentData(e, LocalTransform.FromPosition(pos)); - em.SetComponentData(e, new Health { Current = hp, Max = hp }); - return e; - } - - [Test] - public void Phase_One_When_Above_Half_HP() - { - var (world, group) = TestWorld.Make("Boss_P1", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - var boss = MakeBoss(em, float3.zero, cur: 60f, max: 100f); - MakePlayer(em, new float3(3f, 0f, 0f)); - group.Update(); - Assert.AreEqual((byte)1, em.GetComponentData(boss).Phase, "Above the phase-2 fraction -> phase 1."); - } - } - - [Test] - public void Phase_Two_At_Or_Below_Half_HP() - { - var (world, group) = TestWorld.Make("Boss_P2", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - float cur = 100f * Tuning.BossPhase2HealthFraction; // exactly the boundary - var boss = MakeBoss(em, float3.zero, cur: cur, max: 100f); - MakePlayer(em, new float3(3f, 0f, 0f)); - group.Update(); - Assert.AreEqual((byte)2, em.GetComponentData(boss).Phase, "At/below the fraction -> phase 2."); - } - } - - [Test] - public void No_Living_Expedition_Target_Leaves_Boss_Idle() - { - var (world, group) = TestWorld.Make("Boss_NoTgt", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f); - MakePlayer(em, new float3(3f, 0f, 0f), region: RegionId.Base); // wrong region - MakePlayer(em, new float3(4f, 0f, 0f), region: RegionId.Expedition, hp: 0f); // dead - - group.Update(); - - Assert.AreEqual(float3.zero, em.GetComponentData(boss).Position, "No valid target -> the boss does not move."); - Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "No target -> no telegraph."); - } - } - - [Test] - public void Slam_Lands_Radial_AoE_On_Windup_Elapse() - { - var (world, group) = TestWorld.Make("Boss_Slam", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); // 100 <= 200 -> elapsed - var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius - - group.Update(); - - var dmg = em.GetBuffer(player); - Assert.AreEqual(1, dmg.Length, "A player inside the slam radius takes one hit on landing."); - Assert.AreEqual(Tuning.BossSlamDamage, dmg[0].Amount, 1e-3f); - Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Slam damage is sourced from the boss/environment (-1)."); - Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "Windup cleared after landing."); - } - } - - [Test] - public void Slam_Misses_A_Player_Outside_The_Radius() - { - var (world, group) = TestWorld.Make("Boss_SlamMiss", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); - var player = MakePlayer(em, new float3(Tuning.BossSlamRadius + 1f, 0f, 0f)); // just outside - - group.Update(); - - Assert.AreEqual(0, em.GetBuffer(player).Length, "Outside the slam radius takes no hit."); - } - } - - [Test] - public void Lunge_Windup_Elapse_Commits_Travel_And_Does_Not_Slam() - { - var (world, group) = TestWorld.Make("Boss_Lunge", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 1, windup: 100); // PendingAttack=1 => lunge - var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius, but a LUNGE must not slam - - group.Update(); - - Assert.AreEqual(0, em.GetBuffer(player).Length, "A lunge elapse must NOT deal slam damage (B4 disambiguation)."); - Assert.AreEqual(Tuning.BossLungeSpeed, em.GetComponentData(boss).Speed, 1e-3f, "Lunge travel committed."); - Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "Windup cleared on lunge commit."); - } - } - - [Test] - public void Knockback_Residual_Is_Zeroed_Boss_Is_Immune() - { - var (world, group) = TestWorld.Make("Boss_Knock", tick: 200, server: true); - using (world) - { - var em = world.EntityManager; - var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, knockUntil: 999u); - MakePlayer(em, new float3(3f, 0f, 0f)); - - group.Update(); - - Assert.AreEqual(0u, em.GetComponentData(boss).UntilTick, "The boss is knockback-immune: residual is zeroed."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta deleted file mode 100644 index c84c70e56..000000000 --- a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 407138571a76c6f4f821fcbde92327a5 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs deleted file mode 100644 index 334b8e1b1..000000000 --- a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -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 -{ - /// - /// Plain-Entities EditMode tests for the server-only — the RPC structure-placement - /// handler. A bare world is seeded with StructureCatalog (+ a Turret entry referencing a Prefab-tagged prefab), - /// BaseAnchor, ResourceLedger (+ Ore), NetworkTime, and synthetic BuildPlaceRequest + ReceiveRpcCommandRequest - /// entities. The headline case is co-op atomicity: two same-tick requests for one cell must place EXACTLY one - /// structure and withdraw the cost ONCE (the in-place commit). Also pins cost/plot validation and request cleanup. - /// - public class BuildPlaceSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name, int oreCount) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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(300) }); - - var anchor = em.CreateEntity(typeof(BaseAnchor)); - em.SetComponentData(anchor, new BaseAnchor - { - AnchorPos = new float3(5, 0, 5), - GridOrigin = new float3(0, 0, 0), - CellSize = 2f, - GridDims = new int2(5, 5), - }); - - // Turret prefab: LocalTransform (looked up for placement) + PlacedStructure (SetComponent target on the - // clone) + a real Prefab tag so it is excluded from the live-structure occupancy scan. - var prefab = em.CreateEntity(typeof(LocalTransform), typeof(PlacedStructure)); - em.AddComponent(prefab); - - var catalogE = em.CreateEntity(typeof(StructureCatalog)); - var catalog = em.AddBuffer(catalogE); - catalog.Add(new StructureCatalogEntry - { - Type = StructureType.Turret, Prefab = prefab, CostResourceId = ResourceId.Ore, CostAmount = 10, - }); - - var ledgerE = em.CreateEntity(typeof(ResourceLedger)); - var ledger = em.AddBuffer(ledgerE); - ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = oreCount }); - - return (world, group); - } - - static void MakeBuildRequest(EntityManager em, byte type, int cellX, int cellZ) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ }); - em.AddComponentData(e, default(ReceiveRpcCommandRequest)); - } - - static int StructureCount(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(PlacedStructure)); - return q.CalculateEntityCount(); - } - - static int OreCount(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(ResourceLedger)); - var ledger = em.GetBuffer(q.GetSingletonEntity()); - for (int i = 0; i < ledger.Length; i++) - if (ledger[i].ItemId == ResourceId.Ore) return ledger[i].Count; - return 0; - } - - [Test] - public void Valid_Request_Places_Structure_Withdraws_Cost_And_Destroys_Request() - { - var (world, group) = MakeWorld("BuildValid", oreCount: 50); - using (world) - { - var em = world.EntityManager; - MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1); - - group.Update(); - - Assert.AreEqual(1, StructureCount(em), "A valid request places exactly one structure."); - Assert.AreEqual(40, OreCount(em), "The build cost (10) is withdrawn from the ledger."); - using var reqQ = em.CreateEntityQuery(typeof(BuildPlaceRequest)); - Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed."); - } - } - - [Test] - public void Two_Same_Cell_Requests_Place_Only_One_And_Withdraw_Once() - { - var (world, group) = MakeWorld("BuildAtomic", oreCount: 50); - using (world) - { - var em = world.EntityManager; - MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1); - MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1); - - group.Update(); - - Assert.AreEqual(1, StructureCount(em), - "Two same-tick requests for one cell place exactly one structure (co-op atomicity)."); - Assert.AreEqual(40, OreCount(em), "The cost is withdrawn exactly once, not twice."); - } - } - - [Test] - public void Insufficient_Resources_Places_Nothing() - { - var (world, group) = MakeWorld("BuildPoor", oreCount: 5); - using (world) - { - var em = world.EntityManager; - MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1); - - group.Update(); - - Assert.AreEqual(0, StructureCount(em), "A request that can't afford the cost places nothing."); - Assert.AreEqual(5, OreCount(em), "The ledger is untouched on an unaffordable request."); - } - } - - [Test] - public void Out_Of_Plot_Cell_Places_Nothing() - { - var (world, group) = MakeWorld("BuildOOB", oreCount: 50); - using (world) - { - var em = world.EntityManager; - MakeBuildRequest(em, StructureType.Turret, cellX: 99, cellZ: 99); - - group.Update(); - - Assert.AreEqual(0, StructureCount(em), "An out-of-plot cell places nothing."); - Assert.AreEqual(50, OreCount(em), "No cost is withdrawn for an illegal placement."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta deleted file mode 100644 index bc7727da4..000000000 --- a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 69248c6e19368b246a8aa8b151a8f7b0 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs deleted file mode 100644 index bac797f42..000000000 --- a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Mathematics; - -namespace ProjectM.Tests -{ - /// - /// Pure tests for — the client build-ghost validity (in-plot, unoccupied, - /// affordable) that mirrors the server's authoritative BuildPlaceSystem check, colouring the ground ghost - /// green (valid) vs red (the first failing reason). - /// - public class BuildPreviewMathTests - { - static BaseAnchor Anchor() => new BaseAnchor - { - AnchorPos = new float3(0, 0, 0), - GridOrigin = new float3(0, 0, 0), - CellSize = 1f, - GridDims = new int2(8, 8), - }; - - [Test] - public void InPlot_Unoccupied_Affordable_IsValid() - { - Assert.AreEqual(BuildPreviewMath.Valid, - BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 50, cost: 20)); - } - - [Test] - public void OutOfPlot_Reported_First() - { - Assert.AreEqual(BuildPreviewMath.OutOfPlot, - BuildPreviewMath.Evaluate(Anchor(), new int2(99, 0), occupied: true, have: 0, cost: 999), - "Out-of-plot is reported before occupancy / cost."); - Assert.AreEqual(BuildPreviewMath.OutOfPlot, - BuildPreviewMath.Evaluate(Anchor(), new int2(-1, 3), occupied: false, have: 50, cost: 10)); - } - - [Test] - public void Occupied_Cell_IsBlocked() - { - Assert.AreEqual(BuildPreviewMath.Occupied, - BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: true, have: 50, cost: 10)); - } - - [Test] - public void Unaffordable_When_Have_Below_Cost_Exact_Funds_Ok() - { - Assert.AreEqual(BuildPreviewMath.Unaffordable, - BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 5, cost: 20)); - Assert.AreEqual(BuildPreviewMath.Valid, - BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 20, cost: 20)); - } - } -} diff --git a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta deleted file mode 100644 index 122020578..000000000 --- a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2d3f16d92d3045044a9efd5545320111 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ChargerTests.cs b/Assets/_Project/Tests/EditMode/ChargerTests.cs deleted file mode 100644 index 3a4cf5cb6..000000000 --- a/Assets/_Project/Tests/EditMode/ChargerTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -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 -{ - /// - /// Plain-Entities EditMode tests for the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with - /// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when - /// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and - /// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots - /// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer. - /// - public class ChargerTests - { - static void SetServerTick(World world, uint tick) - { - var em = world.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); - em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - SetServerTick(world, serverTick); - return (world, group); - } - - static Entity MakePlayer(EntityManager em, float3 pos) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); - em.AddComponent(e); - em.AddBuffer(e); - em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); - return e; - } - - static Entity MakeCharger(EntityManager em, float3 pos) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 }); - em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 }); - em.AddComponentData(e, new KnockbackState()); - em.AddComponentData(e, new AttackWindup()); - em.AddComponentData(e, new LungeState()); - em.AddComponent(e); - em.SetComponentEnabled(e, false); // baked DISABLED on the real Charger (spawns not-lunging) - em.AddComponent(e); - em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); - return e; - } - - [Test] - public void Commit_Fires_Even_When_Target_Left_Range() - { - var (world, group) = MakeWorld("ChargerCommit", 200); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6) - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick - - group.Update(); // tick 200 - - var lunge = em.GetComponentData(charger); - Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range)."); - Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X)."); - Assert.AreEqual(0u, em.GetComponentData(charger).WindUpUntilTick, "The wind-up clears on commit."); - } - } - - [Test] - public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window() - { - var (world, group) = MakeWorld("ChargerWhiff", 206); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge - em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable - - group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff - - Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "A whiffed lunge is cleared."); - Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData(charger).NextAttackTick, - "An overshoot whiff extends the attack cooldown by the stagger window (the punish window)."); - using var tq = em.CreateEntityQuery(typeof(DevTelemetry)); - Assert.AreEqual(1u, tq.GetSingleton().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window."); - Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData(charger).StaggerUntilTick, - "The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source)."); - } - } - - [Test] - public void Knockback_Cancels_An_InFlight_Lunge() - { - var (world, group) = MakeWorld("ChargerKnockback", 305); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(10, 1, 0)); - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X - em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X - - group.Update(); // tick 305: knockback (until 315) wins - - Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, - "Knockback cancels the in-flight lunge (no two-writer contention on Position)."); - Assert.Less(em.GetComponentData(charger).Position.x, 0f, - "The recoiling Charger moved along its knockback direction (-X), not its lunge direction."); - } - } - - [Test] - public void Commit_Enables_IsLunging() - { - var (world, group) = MakeWorld("ChargerIsLungingCommit", 200); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(3, 1, 0)); - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick -> commit - Assert.IsFalse(em.IsComponentEnabled(charger), "Charger spawns not-lunging (baked DISABLED)."); - - group.Update(); // tick 200: commit the lunge - - Assert.AreNotEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the lunge committed."); - Assert.IsTrue(em.IsComponentEnabled(charger), - "The replicated mid-lunge cue is ENABLED while a committed lunge is live (.WithPresent visits the disabled entity to write the bit)."); - } - } - - [Test] - public void Whiff_Disables_IsLunging() - { - var (world, group) = MakeWorld("ChargerIsLungingWhiff", 206); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(-10, 1, 0)); - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring - em.SetComponentEnabled(charger, true); // was mid-lunge - - group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge - - Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the whiffed lunge cleared."); - Assert.IsFalse(em.IsComponentEnabled(charger), "The cue clears the tick the lunge ends (whiff)."); - } - } - - [Test] - public void Knockback_Disables_IsLunging() - { - var (world, group) = MakeWorld("ChargerIsLungingKnockback", 305); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, new float3(10, 1, 0)); - var charger = MakeCharger(em, new float3(0, 1, 0)); - em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); - em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); - em.SetComponentEnabled(charger, true); // mid-lunge before the knockback - - group.Update(); // tick 305: knockback cancels the lunge (UntilTick -> 0) via the mid-body continue path - - Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: knockback cancelled the lunge."); - Assert.IsFalse(em.IsComponentEnabled(charger), - "The cue clears when knockback cancels the lunge (covers the mid-body continue exit path)."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ChargerTests.cs.meta b/Assets/_Project/Tests/EditMode/ChargerTests.cs.meta deleted file mode 100644 index 8566cff9a..000000000 --- a/Assets/_Project/Tests/EditMode/ChargerTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c31affe7e592820448b105987c883868 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs deleted file mode 100644 index 913d1c85f..000000000 --- a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -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 -{ - /// - /// Plain-Entities tests for (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. - /// - public class DashTrailDamageSystemTests - { - static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick) - { - var world = new World("DashTrailTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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(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(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(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(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(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(enemy).Length, "no Blade Dash boon -> no trail damage"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta deleted file mode 100644 index 9cce847fa..000000000 --- a/Assets/_Project/Tests/EditMode/DashTrailDamageSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7603c5c6b91bb854d8b88739bdb6f4b1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs deleted file mode 100644 index 70be1d772..000000000 --- a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -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 -{ - /// - /// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup): - /// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players + - /// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two - /// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still - /// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path). - /// - public class EnemyProjectileTests - { - static void SetTick(World w, uint tick) - { - var em = w.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); - em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static (World, SimulationSystemGroup) MoveWorld() - { - var w = new World("EnemyProjMove"); - var g = w.GetOrCreateSystemManaged(); - g.AddSystemToUpdateList(w.GetOrCreateSystem()); - g.SortSystems(); - w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); - return (w, g); - } - - static (World, SimulationSystemGroup) DamageWorld() - { - var w = new World("EnemyProjDmg"); - var g = w.GetOrCreateSystemManaged(); - g.AddSystemToUpdateList(w.GetOrCreateSystem()); - g.SortSystems(); - w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); - SetTick(w, 200); - return (w, g); - } - - static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region }); - return e; - } - - static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); - em.AddComponentData(e, new HitRadius { Value = radius }); - em.AddComponentData(e, new RegionTag { Region = region }); - em.AddBuffer(e); - em.AddComponent(e); - return e; - } - - [Test] - public void Move_IntegratesAndStoresLastStep() - { - var (w, g) = MoveWorld(); - using (w) - { - var em = w.EntityManager; - var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base); - g.Update(); // dt 0.1 * speed 10 = step 1 - var p = em.GetComponentData(spit); - Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)"); - Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f); - Assert.AreEqual(1f, em.GetComponentData(spit).Position.x, 1e-4f, "moved along +X"); - } - } - - [Test] - public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce() - { - var (w, g) = DamageWorld(); - using (w) - { - var em = w.EntityManager; - var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); - var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f); - g.Update(); - Assert.AreEqual(1, em.GetBuffer(player).Length, "same-region player takes the hit"); - Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit"); - } - } - - [Test] - public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer() - { - var (w, g) = DamageWorld(); - using (w) - { - var em = w.EntityManager; - var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); - var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f); - g.Update(); - Assert.AreEqual(0, em.GetBuffer(basePlayer).Length, "cross-region spit must NOT damage an off-region player"); - Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target"); - } - } - - [Test] - public void Damage_SweptSegment_NoTunnelThroughSmallTarget() - { - var (w, g) = DamageWorld(); - using (w) - { - var em = w.EntityManager; - // target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5. - var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f); - var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f); - g.Update(); - Assert.AreEqual(1, em.GetBuffer(player).Length, - "swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta deleted file mode 100644 index 0917da40b..000000000 --- a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e63d6c6d98027f248be3fc163961ca95 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs deleted file mode 100644 index 9acb1ec4d..000000000 --- a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs +++ /dev/null @@ -1,279 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only . Seeds a player - /// (GhostOwner + PlayerTag + InventorySlot + EquipmentSlot[4 rows] + StatModifier), an inline-built - /// ItemDatabase singleton, a mock connection, and an Equip/Unequip RPC. Weapons are STAT-STICKS - /// (LANTERN purge: the old weapon->AbilityRef grant is deleted; abilities live in the socket kit). - /// Pins: weapon-equip adds the slot-tagged mod + moves the item bag->slot; unequip reverses; - /// equip-over-occupied swaps the old item back; a full-bag swap is rejected with no item loss; - /// non-equippable / absent / unresolvable-connection requests no-op (request still consumed); - /// the unequip strip removes ONLY the slot sentinel, leaving foreign-SourceId mods (pickup 0u, upgrade) intact. - /// - public class EquipSystemTests - { - const ushort WeaponA = 100, WeaponB = 101, GearArmor = 110, Ore = 2; - BlobAssetReference _blob; - - [TearDown] - public void TearDown() - { - if (_blob.IsCreated) _blob.Dispose(); - _blob = default; - } - - static ItemModSpec NoMod() => new ItemModSpec { Target = 255 }; - - static ItemDefBlob Mk(ushort id, byte slot, ItemModSpec m0) - { - int stackMax = slot <= EquipSlotId.Tool ? 1 : 999; - return new ItemDefBlob - { - ItemId = id, Category = 0, Tier = 0, StackMax = stackMax, - EquipSlot = slot, - Mod0 = m0, Mod1 = NoMod(), Mod2 = NoMod(), Mod3 = NoMod(), - }; - } - - (World world, SimulationSystemGroup group) MakeWorld(string name) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var arr = builder.Allocate(ref root.Items, 4); - arr[0] = Mk(WeaponA, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f }); - arr[1] = Mk(WeaponB, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f }); - arr[2] = Mk(GearArmor, EquipSlotId.Armor, new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.1f }); - arr[3] = Mk(Ore, EquipSlotId.None, NoMod()); - _blob = builder.CreateBlobAssetReference(Allocator.Persistent); - builder.Dispose(); - var dbE = em.CreateEntity(typeof(ItemDatabase)); - em.SetComponentData(dbE, new ItemDatabase { Value = _blob }); - - return (world, group); - } - - static Entity MakeConnection(EntityManager em, int networkId) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new NetworkId { Value = networkId }); - return e; - } - - static Entity MakePlayer(EntityManager em, int networkId, params (ushort id, int count)[] bagItems) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); - em.AddComponent(e); - var bag = em.AddBuffer(e); - foreach (var it in bagItems) bag.Add(new InventorySlot { ItemId = it.id, Count = it.count }); - var slots = em.AddBuffer(e); - for (int s = 0; s < EquipSlotId.Count; s++) slots.Add(new EquipmentSlot { ItemId = 0 }); - em.AddBuffer(e); - return e; - } - - static void MakeEquip(EntityManager em, ushort itemId, Entity conn) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new EquipRequest { ItemId = itemId }); - em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static void MakeUnequip(EntityManager em, byte slot, Entity conn) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new UnequipRequest { Slot = slot }); - em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static ushort Slot(EntityManager em, Entity p, byte slot) => em.GetBuffer(p)[slot].ItemId; - static int Bag(EntityManager em, Entity p, ushort id) => InventoryMath.CountOf(em.GetBuffer(p), id); - static int RequestsLeft(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ReceiveRpcCommandRequest)); return q.CalculateEntityCount(); } - - static int SlotModCount(EntityManager em, Entity p, byte slot) - { - var mods = em.GetBuffer(p); - uint sid = Tuning.EquipSourceIdBase + (uint)slot; - int c = 0; - for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == sid) c++; - return c; - } - - static int ModCountBySource(EntityManager em, Entity p, uint sourceId) - { - var mods = em.GetBuffer(p); - int c = 0; - for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == sourceId) c++; - return c; - } - - [Test] - public void Equip_Weapon_Adds_Mod_Moves_Item() - { - var (world, group) = MakeWorld("EquipWeapon"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (WeaponA, 1)); - MakeEquip(em, WeaponA, conn); - - group.Update(); - - Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The weapon occupies the Weapon slot."); - Assert.AreEqual(0, Bag(em, player, WeaponA), "The weapon left the bag."); - Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is tagged the weapon-slot sentinel."); - Assert.AreEqual(0, RequestsLeft(em), "The request is consumed."); - } - } - - [Test] - public void Unequip_Weapon_Strips_Mods_Returns_Item() - { - var (world, group) = MakeWorld("UnequipWeapon"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (WeaponA, 1)); - MakeEquip(em, WeaponA, conn); - group.Update(); - - MakeUnequip(em, EquipSlotId.Weapon, conn); - group.Update(); - - Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "The Weapon slot is empty."); - Assert.AreEqual(1, Bag(em, player, WeaponA), "The weapon is back in the bag."); - Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is stripped."); - } - } - - [Test] - public void Equip_Over_Occupied_Swaps_Old_Item_Back() - { - var (world, group) = MakeWorld("EquipSwap"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (WeaponA, 1), (WeaponB, 1)); - MakeEquip(em, WeaponA, conn); - group.Update(); - - MakeEquip(em, WeaponB, conn); - group.Update(); - - Assert.AreEqual(WeaponB, Slot(em, player, EquipSlotId.Weapon), "Weapon B now occupies the slot."); - Assert.AreEqual(1, Bag(em, player, WeaponA), "Weapon A swapped back into the bag."); - Assert.AreEqual(0, Bag(em, player, WeaponB), "Weapon B left the bag."); - Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "Exactly weapon B's single mod remains (A's stripped)."); - } - } - - [Test] - public void Swap_With_Full_Bag_Is_Rejected_No_Item_Loss() - { - var (world, group) = MakeWorld("FullBagSwap"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - // Pre-equip weapon A directly, then fill the bag completely (incl. weapon B). Equipping B must - // reject because the bag has no room to receive the swapped-out weapon A. - var player = MakePlayer(em, 1, (WeaponB, 1)); - var preSlots = em.GetBuffer(player); - preSlots[EquipSlotId.Weapon] = new EquipmentSlot { ItemId = WeaponA }; - var bag = em.GetBuffer(player); - for (int i = 0; bag.Length < Tuning.InventoryMaxSlots; i++) - bag.Add(new InventorySlot { ItemId = (ushort)(200 + i), Count = 1 }); - - MakeEquip(em, WeaponB, conn); - group.Update(); - - Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The occupied slot is unchanged (equip rejected)."); - Assert.AreEqual(1, Bag(em, player, WeaponB), "Weapon B was NOT withdrawn — no item loss."); - Assert.AreEqual(0, RequestsLeft(em), "The request is still consumed."); - } - } - - [Test] - public void Equip_NonEquippable_Or_Absent_Item_Is_NoOp() - { - var (world, group) = MakeWorld("NoOpEquip"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (Ore, 5)); // carries a resource (EquipSlot=None) but no weapon - MakeEquip(em, Ore, conn); // not equippable - MakeEquip(em, WeaponA, conn); // not in the bag - group.Update(); - - Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "Nothing equipped."); - Assert.AreEqual(5, Bag(em, player, Ore), "The resource is untouched."); - Assert.AreEqual(0, RequestsLeft(em), "Both requests are consumed."); - } - } - - [Test] - public void Equip_From_Unresolvable_Connection_NoOp() - { - var (world, group) = MakeWorld("UnresolvedEquip"); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, 1, (WeaponA, 1)); - MakeEquip(em, WeaponA, Entity.Null); // no NetworkId on Entity.Null - - group.Update(); - - Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "An unresolvable sender equips nothing."); - Assert.AreEqual(1, Bag(em, player, WeaponA), "The item stays in the bag."); - Assert.AreEqual(0, RequestsLeft(em), "The request is still consumed."); - } - } - - [Test] - public void Strip_Removes_Only_The_Slot_Sentinel_Leaving_Foreign_Mods() - { - var (world, group) = MakeWorld("StripIsolation"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (GearArmor, 1)); - // Seed foreign modifiers that unequip must NOT touch: a pickup (SourceId 0) + the ability upgrade. - var mods = em.GetBuffer(player); - mods.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 3f, SourceId = 0u }); - mods.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentAdd, Value = 0.25f, SourceId = Tuning.AbilityUpgradeSourceId }); - - MakeEquip(em, GearArmor, conn); - group.Update(); - Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Armor), "Gear adds its armor-slot mod."); - - MakeUnequip(em, EquipSlotId.Armor, conn); - group.Update(); - - Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Armor), "Unequip strips the armor-slot mod."); - Assert.AreEqual(1, ModCountBySource(em, player, 0u), "The pickup mod (SourceId 0) is untouched."); - Assert.AreEqual(1, ModCountBySource(em, player, Tuning.AbilityUpgradeSourceId), "The upgrade mod is untouched."); - Assert.AreEqual(1, Bag(em, player, GearArmor), "The gear returns to the bag."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta deleted file mode 100644 index 5aac4816a..000000000 --- a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5aa8cc2d95b243d49b7acb4c184df7f2 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs deleted file mode 100644 index 93ac4c15c..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only — the RPC that - /// moves a player's PERSONAL inventory into the shared ledger. Mirrors the RPC-receive tests' seeding: - /// a ResourceLedger singleton, a mock connection (NetworkId), a player (GhostOwner + InventorySlot + - /// PlayerTag), and an InventoryDepositRequest + ReceiveRpcCommandRequest. Pins: a specific-item deposit - /// moves the clamped amount; ItemId 0 deposits everything and empties the bag; an unresolvable connection - /// moves nothing; the request is consumed either way. - /// - public class InventoryDepositSystemTests - { - static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var ledger = em.CreateEntity(typeof(ResourceLedger)); - em.AddBuffer(ledger); - return (world, group, ledger); - } - - static Entity MakeConnection(EntityManager em, int networkId) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new NetworkId { Value = networkId }); - return e; - } - - static Entity MakePlayer(EntityManager em, int networkId, params (ushort id, int count)[] items) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); - em.AddComponent(e); - var bag = em.AddBuffer(e); - foreach (var it in items) - bag.Add(new InventorySlot { ItemId = it.id, Count = it.count }); - return e; - } - - static void MakeRequest(EntityManager em, ushort itemId, int count, Entity conn) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new InventoryDepositRequest { ItemId = itemId, Count = count }); - em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int LedgerCount(EntityManager em, Entity ledger, ushort itemId) - { - var buf = em.GetBuffer(ledger); - for (int i = 0; i < buf.Length; i++) - if (buf[i].ItemId == itemId) return buf[i].Count; - return 0; - } - - static int InvCount(EntityManager em, Entity player, ushort itemId) - { - var buf = em.GetBuffer(player); - return InventoryMath.CountOf(buf, itemId); - } - - [Test] - public void Deposit_Specific_Item_Moves_Clamped_Amount_To_Ledger() - { - var (world, group, ledger) = MakeWorld("DepositSpecific"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (ResourceId.Ore, 30)); - MakeRequest(em, ResourceId.Ore, 20, conn); - - group.Update(); - - Assert.AreEqual(10, InvCount(em, player, ResourceId.Ore), "20 of 30 Ore moved out of the bag."); - Assert.AreEqual(20, LedgerCount(em, ledger, ResourceId.Ore), "20 Ore landed in the shared ledger."); - using var q = em.CreateEntityQuery(typeof(InventoryDepositRequest)); - Assert.AreEqual(0, q.CalculateEntityCount(), "The request is consumed."); - } - } - - [Test] - public void Deposit_All_Empties_Bag_Into_Ledger() - { - var (world, group, ledger) = MakeWorld("DepositAll"); - using (world) - { - var em = world.EntityManager; - var conn = MakeConnection(em, 1); - var player = MakePlayer(em, 1, (ResourceId.Ore, 30), (ResourceId.Aether, 5)); - MakeRequest(em, itemId: 0, count: 0, conn); // 0 = deposit all - - group.Update(); - - Assert.AreEqual(0, InvCount(em, player, ResourceId.Ore), "Deposit-all empties the bag."); - Assert.AreEqual(0, InvCount(em, player, ResourceId.Aether)); - Assert.AreEqual(30, LedgerCount(em, ledger, ResourceId.Ore)); - Assert.AreEqual(5, LedgerCount(em, ledger, ResourceId.Aether)); - } - } - - [Test] - public void Deposit_From_Unresolvable_Connection_Moves_Nothing() - { - var (world, group, ledger) = MakeWorld("DepositUnknown"); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, 1, (ResourceId.Ore, 30)); - MakeRequest(em, ResourceId.Ore, 20, Entity.Null); // no NetworkId on Entity.Null - - group.Update(); - - Assert.AreEqual(30, InvCount(em, player, ResourceId.Ore), "An unresolvable sender moves nothing."); - Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Ore)); - using var q = em.CreateEntityQuery(typeof(InventoryDepositRequest)); - Assert.AreEqual(0, q.CalculateEntityCount(), "The request is still consumed."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta deleted file mode 100644 index 3d1127cf0..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e0a222e00ad08444793bf5b1ffccc71a \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs deleted file mode 100644 index c742174b8..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -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 -{ - /// - /// Plain-Entities EditMode tests for the harvest -> PERSONAL inventory reroute in - /// . A bare world is seeded with a ResourceLedger singleton, a node, - /// a player (GhostOwner + InventorySlot + PlayerTag) and an OWNED projectile (matching GhostOwner). Pins: - /// an owned hit lands in the player's inventory and leaves the ledger untouched; a full bag spills the - /// remainder to the ledger; an owned projectile whose NetworkId has no live player falls back to the ledger. - /// The 8 owner-less tests in pin the un-owned -> ledger fallback. - /// - public class InventoryHarvestTests - { - static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var ledger = em.CreateEntity(typeof(ResourceLedger)); - em.AddBuffer(ledger); - return (world, group, ledger); - } - - static Entity MakeNode(EntityManager em, float3 pos, float hitRadius, byte resourceId, int remaining, float perHit) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new HitRadius { Value = hitRadius }); - em.AddComponentData(e, new ResourceNode { ResourceId = resourceId, Remaining = remaining, HarvestPerHit = perHit }); - return e; - } - - static Entity MakePlayer(EntityManager em, int networkId) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); - em.AddComponent(e); - em.AddBuffer(e); - return e; - } - - static Entity MakeOwnedProjectile(EntityManager em, float3 pos, float2 dir, float lastStep, int networkId) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new Projectile { Direction = dir, LastStep = lastStep }); - em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); - return e; - } - - static int LedgerCount(EntityManager em, Entity ledger, ushort itemId) - { - var buf = em.GetBuffer(ledger); - for (int i = 0; i < buf.Length; i++) - if (buf[i].ItemId == itemId) return buf[i].Count; - return 0; - } - - static int InvCount(EntityManager em, Entity player, ushort itemId) - { - var buf = em.GetBuffer(player); - return InventoryMath.CountOf(buf, itemId); - } - - [Test] - public void Owned_Harvest_Lands_In_Player_Inventory_Ledger_Untouched() - { - var (world, group, ledger) = MakeWorld("OwnedHarvest"); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, networkId: 1); - var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Aether, remaining: 100, perHit: 25f); - var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 1); - - group.Update(); - - Assert.AreEqual(25, InvCount(em, player, ResourceId.Aether), "The owner's harvest lands in their personal inventory."); - Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "The shared ledger is untouched by an owned harvest."); - Assert.AreEqual(75, em.GetComponentData(node).Remaining); - Assert.IsFalse(em.Exists(proj), "The projectile is consumed."); - } - } - - [Test] - public void Full_Bag_Spills_Remainder_To_Ledger() - { - var (world, group, ledger) = MakeWorld("FullBagSpill"); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, networkId: 1); - // Fill all InventoryMaxSlots with distinct dummy items so no slot is free for the harvested id. - var bag = em.GetBuffer(player); - for (int i = 0; i < Tuning.InventoryMaxSlots; i++) - bag.Add(new InventorySlot { ItemId = (ushort)(100 + i), Count = 1 }); - - var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Ore, remaining: 100, perHit: 25f); - var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 1); - - group.Update(); - - Assert.AreEqual(0, InvCount(em, player, ResourceId.Ore), "A full bag cannot take the harvested item."); - Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Ore), "The full amount spills to the shared ledger."); - Assert.AreEqual(75, em.GetComponentData(node).Remaining, "The node decrements by the FULL amount, not just the part that fit."); - Assert.IsFalse(em.Exists(proj)); - } - } - - [Test] - public void Owned_Projectile_With_No_Matching_Player_Falls_Back_To_Ledger() - { - var (world, group, ledger) = MakeWorld("NoMatchingPlayer"); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, networkId: 1); - var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Aether, remaining: 100, perHit: 25f); - // Projectile owned by NetworkId 99 — no live player has that id. - var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 99); - - group.Update(); - - Assert.AreEqual(0, InvCount(em, player, ResourceId.Aether), "Player 1 gets nothing — it didn't fire this shot."); - Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Aether), "An unresolvable owner falls back to the shared ledger."); - Assert.IsFalse(em.Exists(proj)); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta deleted file mode 100644 index 941ac28f1..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 42fed61a7f84fd740b17ec2c4cff8204 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs deleted file mode 100644 index ffb22ebf9..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Entities; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the pure stacking logic (the per-player - /// bag math): top-up-then-append with a per-item stack cap, a max slot count returning a remainder, and - /// back-to-front withdraw clamped to availability. A bare world hosts an entity that owns the - /// buffer (a DynamicBuffer needs an entity); no systems run. - /// - public class InventoryMathTests - { - static (World world, DynamicBuffer buffer) MakeBuffer() - { - var world = new World("InventoryMathTest"); - var em = world.EntityManager; - var e = em.CreateEntity(); - var buffer = em.AddBuffer(e); - return (world, buffer); - } - - [Test] - public void Deposit_TopsUpExistingStack_ThenAppends_NewStack() - { - var (world, buf) = MakeBuffer(); - using (world) - { - int r1 = InventoryMath.Deposit(buf, itemId: 1, count: 5, stackMax: 10, maxSlots: 4); - Assert.AreEqual(0, r1, "5 fits in one fresh stack."); - Assert.AreEqual(1, buf.Length); - - int r2 = InventoryMath.Deposit(buf, itemId: 1, count: 8, stackMax: 10, maxSlots: 4); - Assert.AreEqual(0, r2, "8 more tops the first stack to 10 then appends 3."); - Assert.AreEqual(2, buf.Length, "A second stack is appended once the first fills."); - Assert.AreEqual(13, InventoryMath.CountOf(buf, 1)); - Assert.AreEqual(10, buf[0].Count, "First stack is capped at stackMax."); - Assert.AreEqual(3, buf[1].Count); - } - } - - [Test] - public void Deposit_FillsMultipleStacks_UpToSlotCap_ReturnsRemainder() - { - var (world, buf) = MakeBuffer(); - using (world) - { - // 2 slots * stackMax 10 = 20 capacity; depositing 25 leaves a remainder of 5. - int r = InventoryMath.Deposit(buf, itemId: 2, count: 25, stackMax: 10, maxSlots: 2); - Assert.AreEqual(5, r, "Past the slot cap, the overflow is returned as a remainder."); - Assert.AreEqual(2, buf.Length); - Assert.AreEqual(20, InventoryMath.CountOf(buf, 2)); - } - } - - [Test] - public void Withdraw_TakesAcrossStacks_BackToFront_Clamped_ReturnsTaken() - { - var (world, buf) = MakeBuffer(); - using (world) - { - InventoryMath.Deposit(buf, itemId: 3, count: 25, stackMax: 10, maxSlots: 4); // 10,10,5 - int taken = InventoryMath.Withdraw(buf, itemId: 3, count: 12); - Assert.AreEqual(12, taken); - Assert.AreEqual(13, InventoryMath.CountOf(buf, 3)); - - int takenAll = InventoryMath.Withdraw(buf, itemId: 3, count: 100); - Assert.AreEqual(13, takenAll, "Withdraw clamps to what is available."); - Assert.AreEqual(0, InventoryMath.CountOf(buf, 3)); - Assert.AreEqual(0, buf.Length, "Emptied stacks are dropped."); - } - } - - [Test] - public void Deposit_ZeroItemId_OrNonPositiveCount_AreNoOps() - { - var (world, buf) = MakeBuffer(); - using (world) - { - Assert.AreEqual(7, InventoryMath.Deposit(buf, itemId: 0, count: 7, stackMax: 10, maxSlots: 4), - "Depositing the empty id deposits nothing and returns the full count."); - Assert.AreEqual(0, InventoryMath.Deposit(buf, itemId: 1, count: 0, stackMax: 10, maxSlots: 4)); - Assert.AreEqual(0, InventoryMath.Deposit(buf, itemId: 1, count: -3, stackMax: 10, maxSlots: 4)); - Assert.AreEqual(0, buf.Length, "No rows were written."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta deleted file mode 100644 index 39d7f04ac..000000000 --- a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: dbdfefb1a0acad849b84fadfb2938d9e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs deleted file mode 100644 index e1e703bac..000000000 --- a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Tests -{ - /// - /// Regression guard for the Phase 1 inline-mod design: returns the - /// def BY VALUE, so the inline slots must survive that copy. (A nested BlobArray of - /// mods would corrupt its relative-offset pointer on this copy and read empty — the blocker the inline layout - /// avoids.) Looks up the SECOND item by id and reads a non-zero mod value: an index-1 lookup + a non-zero read - /// is exactly what would expose an offset corruption that a length-only check on item 0 could miss. - /// - public class ItemDatabaseBlobTests - { - [Test] - public void TryGetItem_RoundTrips_Inline_Mods_For_Second_Item() - { - var builder = new BlobBuilder(Allocator.Temp); - ref var root = ref builder.ConstructRoot(); - var arr = builder.Allocate(ref root.Items, 2); - arr[0] = new ItemDefBlob { ItemId = 100, EquipSlot = EquipSlotId.Weapon }; - arr[1] = new ItemDefBlob - { - ItemId = 101, - EquipSlot = EquipSlotId.Armor, - Mod0 = new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.25f }, - Mod1 = new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 7f }, - Mod2 = new ItemModSpec { Target = 255 }, - Mod3 = new ItemModSpec { Target = 255 }, - }; - var blob = builder.CreateBlobAssetReference(Allocator.Persistent); - builder.Dispose(); - - try - { - ref var db = ref blob.Value; - Assert.IsTrue(db.TryGetItem(101, out var def), "Second item resolves by id."); - Assert.AreEqual(EquipSlotId.Armor, def.EquipSlot); - - var m0 = def.GetMod(0); - Assert.AreEqual((byte)StatTarget.MoveSpeed, m0.Target, "Inline Mod0 target survives the by-value copy."); - Assert.AreEqual(0.25f, m0.Value, 1e-4f, "Inline Mod0 value survives the by-value copy (would read 0 under a nested-blob corruption)."); - - var m1 = def.GetMod(1); - Assert.AreEqual((byte)StatTarget.Damage, m1.Target); - Assert.AreEqual(7f, m1.Value, 1e-4f); - - Assert.AreEqual(255, def.GetMod(2).Target, "Unused inline slots stay 255."); - } - finally { blob.Dispose(); } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta deleted file mode 100644 index cd7bae077..000000000 --- a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4933b5824e154374494e80fa6ceaa81c \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs deleted file mode 100644 index 5c62fc2ce..000000000 --- a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities tests for (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 < 0) grants nothing but is still latched. - /// - public class KillRewardSystemTests - { - const uint T0 = 5000; - - static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld() - { - var world = new World("KillRewardTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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(e); - em.AddBuffer(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(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(killer).Current, 50f, "Siphon healed the killer"); - Assert.AreEqual(1, em.GetComponentData(corpse).Rewarded, "corpse latched as rewarded"); - - float afterFirst = em.GetComponentData(killer).Current; - group.Update(); // second tick: Rewarded==1 -> no double-heal - Assert.AreEqual(afterFirst, em.GetComponentData(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(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(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(corpse).Rewarded, "still latched so it is not reprocessed"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta deleted file mode 100644 index 8b7552d9e..000000000 --- a/Assets/_Project/Tests/EditMode/KillRewardSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4e10a4fa71c531a42b093a1b43d1ccaf \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs index 3735e66aa..956e6b224 100644 --- a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs +++ b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs @@ -484,32 +484,7 @@ namespace ProjectM.Tests } } - [Test] - public void Cleave_Harvests_An_Expedition_Node_To_Personal_Inventory_Not_The_Ledger() - { - var (world, group) = MakeWorld("MeleeHarvestExp", 100, server: true); - using (world) - { - var em = world.EntityManager; - var ledger = em.CreateEntity(typeof(ResourceLedger)); - em.AddBuffer(ledger); - var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7 - em.AddComponent(p); - em.AddBuffer(p); - var node = em.CreateEntity(); - em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2))); - em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f }); - em.AddComponentData(node, new RegionTag { Region = RegionId.Expedition }); - Press(em, p); - group.Update(); - - var inv = em.GetBuffer(p); - Assert.AreEqual(5, InventoryMath.CountOf(inv, ResourceId.Aether), "an expedition-node melee hit lands in the swinging player's PERSONAL inventory."); - Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "an expedition harvest does NOT credit the shared base ledger (DR-026 personal haul)."); - Assert.AreEqual(25, em.GetComponentData(node).Remaining, "the node is still depleted."); - } - } diff --git a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs deleted file mode 100644 index c6a13fdf9..000000000 --- a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Pins Step 12a — the born-correct PERMANENT meta seeding in : a spawning - /// player replays its class's persisted tiers as meta-band StatModifiers - /// (Value = ValuePerTier * tier, SourceId = MetaSourceIdBase + id); other-class rows and unknown ids are - /// skipped; an over-MaxTier saved row is CLAMPED (D-F5); and the availability guard blocks the WHOLE spawn - /// (request preserved, nothing consumed) when the catalog is absent (N2). - /// - public class MetaSeedingTests - { - static (World world, SimulationSystemGroup group) MakeWorld() - { - var world = new World("MetaSeedTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - return (world, group); - } - - static Entity MakePlayerPrefab(EntityManager em) - { - var e = em.CreateEntity(typeof(LocalTransform), typeof(GhostOwner), typeof(FrameId), typeof(PlayerTag)); - em.SetComponentData(e, LocalTransform.Identity); - em.AddBuffer(e); - em.AddBuffer(e); // the spawn path SetBuffers the frame loadout unconditionally (LANTERN purge) - em.AddComponent(e); - return e; - } - - static void MakeSpawnRequest(EntityManager em, byte classId) - { - var conn = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(conn, new NetworkId { Value = 1 }); - em.AddBuffer(conn); - var req = em.CreateEntity(typeof(GoInGameRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(req, new GoInGameRequest { ClassId = classId }); - em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int MetaRows(EntityManager em, Entity player, out float firstValue, out uint firstSource) - { - firstValue = 0f; firstSource = 0; - var mods = em.GetBuffer(player); - int n = 0; - for (int i = 0; i < mods.Length; i++) - if (mods[i].SourceId >= Tuning.MetaSourceIdBase - && mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan) - { - if (n == 0) { firstValue = mods[i].Value; firstSource = mods[i].SourceId; } - n++; - } - return n; - } - - [Test] - public void Spawn_SeedsClassTiers_SkipsOthers_ClampsOverMax() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var prefab = MakePlayerPrefab(em); - var spawnerE = em.CreateEntity(typeof(PlayerSpawner)); - em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 }); - - var catalogE = em.CreateEntity(typeof(MetaUpgradeCatalog)); - em.SetComponentData(catalogE, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() }); - var record = em.AddBuffer(catalogE); // the tier record rides any singleton entity in tests - byte warrior = ClassTraits.WarriorClass; // normalized FrameKind (2) - record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 1, Tier = 2 }); // Reinforced Frame t2 -> +30 - record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 5, Tier = 9 }); // Warrior's Might, saved OVER MaxTier(4) -> clamp - record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 200, Tier = 1 }); // unknown id -> skipped - record.Add(new MetaTierState { ClassId = ClassTraits.RangerClass, UpgradeId = 2, Tier = 3 }); // other class -> skipped - record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 7, Tier = 1 }); // Ranger-masked (Longshot) -> skipped - - MakeSpawnRequest(em, warrior); - group.Update(); - - var pq = em.CreateEntityQuery(typeof(PlayerTag), typeof(PlayerClass)); - var players = pq.ToEntityArray(Allocator.Temp); - Assert.AreEqual(1, players.Length, "player spawned"); - var player = players[0]; - players.Dispose(); pq.Dispose(); // BEFORE the world - - var mods = em.GetBuffer(player); - float frameValue = 0f, mightValue = 0f; - int metaRows = 0; - for (int i = 0; i < mods.Length; i++) - { - if (mods[i].SourceId == Tuning.MetaSourceIdBase + 1) { frameValue = mods[i].Value; metaRows++; } - else if (mods[i].SourceId == Tuning.MetaSourceIdBase + 5) { mightValue = mods[i].Value; metaRows++; } - else if (mods[i].SourceId >= Tuning.MetaSourceIdBase - && mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan) metaRows++; - } - Assert.AreEqual(2, metaRows, "exactly the two legal Warrior tiers seeded (unknown/other-class/other-mask skipped)"); - Assert.AreEqual(30f, frameValue, 1e-3f, "Reinforced Frame tier 2 = 15 * 2"); - Assert.AreEqual(0.40f, mightValue, 1e-3f, "Warrior's Might CLAMPED to MaxTier 4 = 0.10 * 4 (D-F5)"); - } - } - - [Test] - public void MissingCatalog_BlocksSpawn_PreservesRequest() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var prefab = MakePlayerPrefab(em); - var spawnerE = em.CreateEntity(typeof(PlayerSpawner)); - em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 }); - // NO catalog, NO tier record. - MakeSpawnRequest(em, ClassTraits.WarriorClass); - - group.Update(); - - var pq = em.CreateEntityQuery(typeof(PlayerClass)); - Assert.AreEqual(0, pq.CalculateEntityCount(), "no player spawned while blocked (N2)"); - var rq = em.CreateEntityQuery(typeof(GoInGameRequest)); - Assert.AreEqual(1, rq.CalculateEntityCount(), "request PRESERVED — the spawn retries when the catalog streams in"); - pq.Dispose(); rq.Dispose(); // BEFORE the world (a using-var here would outlive it)in"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta deleted file mode 100644 index dd6ec01a1..000000000 --- a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 923f2724cee01c34f87f8fda67885b11 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs deleted file mode 100644 index 990ad4b08..000000000 --- a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Pins Step 13 — : the Staging-gated Aether→tier purchase with DR-014 in-loop - /// ledger atomicity (two same-tick barely-enough purchases → exactly ONE succeeds), the TotalOf pre-check - /// (Withdraw clamps, it never rejects), the ABSOLUTE-value modifier upsert on every live class member (R-F1/2), - /// tier bump-or-append on the director record, the SaveRequest flag, and the reject paths (wrong class mask, - /// MaxTier cap, non-Staging lifecycle) — with requests always consumed. - /// - public class MetaSpendSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld() - { - var world = new World("MetaSpendTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - return (world, group); - } - - static Entity MakeDirector(EntityManager em, byte lifecycle, int aether) - { - var dir = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(SaveRequest), - typeof(MetaUpgradeCatalog)); - em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle }); - em.SetComponentData(dir, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() }); - var ledger = em.AddBuffer(dir); - if (aether > 0) ledger.Add(new StorageEntry { ItemId = ResourceId.Aether, Count = aether }); - em.AddBuffer(dir); - return dir; - } - - static Entity MakePlayer(EntityManager em, int networkId, byte classId) - { - var conn = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(conn, new NetworkId { Value = networkId }); - var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(PlayerClass)); - em.SetComponentData(player, new GhostOwner { NetworkId = networkId }); - em.SetComponentData(player, new PlayerClass { ClassId = classId }); - em.AddBuffer(player); - em.AddComponentData(player, new ConnRef { Conn = conn }); - return player; - } - - /// Test-only pointer so a request can be issued from the player's own connection. - struct ConnRef : IComponentData { public Entity Conn; } - - static void SendRequest(EntityManager em, Entity player, byte upgradeId) - { - var conn = em.GetComponentData(player).Conn; - var req = em.CreateEntity(typeof(MetaSpendRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(req, new MetaSpendRequest { UpgradeId = upgradeId }); - em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int PendingRequests(EntityManager em) - { - var q = em.CreateEntityQuery(typeof(MetaSpendRequest)); - int n = q.CalculateEntityCount(); - q.Dispose(); - return n; - } - - static float MetaModValue(EntityManager em, Entity player, byte upgradeId, out int rowCount) - { - var mods = em.GetBuffer(player, true); - float value = 0f; - rowCount = 0; - for (int i = 0; i < mods.Length; i++) - if (mods[i].SourceId == Tuning.MetaSourceIdBase + upgradeId) { value = mods[i].Value; rowCount++; } - return value; - } - - [Test] - public void Purchase_BumpsTier_Withdraws_UpsertsAllClassMembers_FlagsSave() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, 25); - var warriorA = MakePlayer(em, 1, ClassTraits.WarriorClass); - var warriorB = MakePlayer(em, 2, ClassTraits.WarriorClass); // classmate: shared per-class pool - var ranger = MakePlayer(em, 3, ClassTraits.RangerClass); // other class: untouched - - SendRequest(em, warriorA, 1); // Reinforced Frame: BaseCost 10, +15/tier - group.Update(); - - var record = em.GetBuffer(dir, true); - Assert.AreEqual(1, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier bumped to 1"); - Assert.AreEqual(15, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), - "cost 10 withdrawn from 25"); - Assert.AreEqual(15f, MetaModValue(em, warriorA, 1, out int rowsA), 1e-3f, "buyer modifier = 15 * tier1"); - Assert.AreEqual(1, rowsA); - Assert.AreEqual(15f, MetaModValue(em, warriorB, 1, out _), 1e-3f, "live classmate upserted too (R-F2)"); - Assert.AreEqual(0f, MetaModValue(em, ranger, 1, out int rowsR), 1e-3f, "other class untouched"); - Assert.AreEqual(0, rowsR); - Assert.AreEqual(1, em.GetComponentData(dir).Pending, "purchase flags the autosave"); - Assert.AreEqual(0, PendingRequests(em), "request consumed"); - } - } - - [Test] - public void TwoSameTick_BarelyEnough_ExactlyOneSucceeds() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, 10); // ids 1 and 4 BOTH cost 10 at tier 0 - var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); - - SendRequest(em, warrior, 1); - SendRequest(em, warrior, 4); - group.Update(); - - var record = em.GetBuffer(dir, true); - int bought = MetaMath.TierOf(record, ClassTraits.WarriorClass, 1) - + MetaMath.TierOf(record, ClassTraits.WarriorClass, 4); - Assert.AreEqual(1, bought, "in-loop atomicity: barely-enough Aether buys exactly ONE (DR-014)"); - Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), - "the single cost fully drained the ledger — and never went negative (TotalOf pre-check)"); - Assert.AreEqual(0, PendingRequests(em), "both requests consumed"); - } - } - - [Test] - public void SecondPurchase_AbsoluteUpsert_SingleRow_RampedCost() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, 30); // tier1 = 10, tier2 = 10 + 1*5 = 15 - var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); - - SendRequest(em, warrior, 1); - group.Update(); - SendRequest(em, warrior, 1); - group.Update(); - - var record = em.GetBuffer(dir, true); - Assert.AreEqual(2, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier 2 owned"); - Assert.AreEqual(1, record.Length, "record row BUMPED in place, not duplicated"); - Assert.AreEqual(30f, MetaModValue(em, warrior, 1, out int rows), 1e-3f, - "ABSOLUTE upsert: 15 * tier2 (R-F1)"); - Assert.AreEqual(1, rows, "one modifier row — an incremental append would double-count in recompute"); - Assert.AreEqual(5, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), - "linear ramp: 30 - 10 - 15"); - } - } - - [Test] - public void Rejects_WrongClassMask_MaxTierCap_NonStaging() - { - var (world, group) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, 999); - var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); - - // (a) Ranger-masked upgrade requested by a Warrior — dropped, nothing withdrawn. - SendRequest(em, warrior, 7); // Ranger's Longshot (mask 2) - group.Update(); - Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), - "class-mask reject leaves the ledger untouched"); - - // (b) at MaxTier — dropped. Fleet Stride (id 4) MaxTier 3. - var record = em.GetBuffer(dir); - record.Add(new MetaTierState { ClassId = ClassTraits.WarriorClass, UpgradeId = 4, Tier = 3 }); - SendRequest(em, warrior, 4); - group.Update(); - Assert.AreEqual(3, MetaMath.TierOf(em.GetBuffer(dir, true), ClassTraits.WarriorClass, 4), - "MaxTier cap holds"); - Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether)); - - // (c) mid-run — dropped (N4: the shop is a between-runs surface). - em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom }); - SendRequest(em, warrior, 1); - group.Update(); - Assert.AreEqual(0, MetaMath.TierOf(em.GetBuffer(dir, true), ClassTraits.WarriorClass, 1), - "non-Staging purchase dropped"); - Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether)); - Assert.AreEqual(0, PendingRequests(em), "every request consumed, accepted or not"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta deleted file mode 100644 index fa0234433..000000000 --- a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4b835275276dd3149b248a6b6a031ad3 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs deleted file mode 100644 index 33a667f85..000000000 --- a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode coverage for the server-only PrepPurchaseSystem (DR-046 base prep-loadout spend). - /// Exercises the afford / soft-fail / once-per-run / non-Staging-reject paths and the DR-014 same-tick atomicity - /// (a second barely-affordable buy in the same tick can't also pass). Modeled on MetaSpendSystemTests. - /// - public class PrepPurchaseSystemTests - { - // Director carries the RunInfo singleton + the shared ResourceLedger (StorageEntry buffer), seeded with one resource. - static Entity MakeDirector(EntityManager em, byte lifecycle, byte resId, int amount) - { - var e = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(StorageEntry)); - em.SetComponentData(e, new RunInfo { Lifecycle = lifecycle }); - if (amount > 0) - { - var ledger = em.GetBuffer(e); - StorageMath.Deposit(ledger, resId, amount); - } - return e; - } - - // A player (PlayerTag + GhostOwner + StatModifier buffer) plus its connection entity (NetworkId). - static (Entity player, Entity conn) MakePlayer(EntityManager em, int netId) - { - var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(StatModifier)); - em.SetComponentData(player, new GhostOwner { NetworkId = netId }); - var conn = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(conn, new NetworkId { Value = netId }); - return (player, conn); - } - - static void SendRequest(EntityManager em, Entity conn, byte optionId) - { - var e = em.CreateEntity(typeof(PrepPurchaseRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(e, new PrepPurchaseRequest { OptionId = optionId }); - em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - // Count StatModifier rows in the prep SourceId band on a player. - static int PrepRowCount(EntityManager em, Entity player) - { - var mods = em.GetBuffer(player); - int n = 0; - for (int i = 0; i < mods.Length; i++) - if (mods[i].SourceId >= Tuning.PrepSourceIdBase && mods[i].SourceId < Tuning.PrepSourceIdBase + 256u) - n++; - return n; - } - - static int OpenRequests(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(PrepPurchaseRequest)); - return q.CalculateEntityCount(); - } - - [Test] - public void Purchase_Appends_Prep_Modifier_Withdraws_And_Consumes_Request() - { - var (world, group) = TestWorld.Make("Prep_Buy", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 30); - var (player, conn) = MakePlayer(em, 1); - SendRequest(em, conn, 0); // id0: Ore 30 -> MaxHealth +30 Flat - - group.Update(); - - Assert.AreEqual(1, PrepRowCount(em, player), "One prep modifier appended."); - var mods = em.GetBuffer(player); - Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target); - Assert.AreEqual(30f, mods[0].Value, 1e-4f); - Assert.AreEqual(Tuning.PrepSourceIdBase + 0u, mods[0].SourceId); - Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Ore fully withdrawn."); - Assert.AreEqual(0, OpenRequests(em), "Request consumed."); - } - } - - [Test] - public void Reject_When_Broke_Leaves_Ledger_Untouched_But_Consumes_Request() - { - var (world, group) = TestWorld.Make("Prep_Broke", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 20); // < 30 cost - var (player, conn) = MakePlayer(em, 1); - SendRequest(em, conn, 0); - - group.Update(); - - Assert.AreEqual(0, PrepRowCount(em, player), "No modifier when broke."); - Assert.AreEqual(20, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Pre-check skips Withdraw; ledger untouched."); - Assert.AreEqual(0, OpenRequests(em), "Request still consumed on reject."); - } - } - - [Test] - public void Already_Owned_Does_Not_Rebuy() - { - var (world, group) = TestWorld.Make("Prep_Owned", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60); - var (player, conn) = MakePlayer(em, 1); - em.GetBuffer(player).Add(new StatModifier { SourceId = Tuning.PrepSourceIdBase + 0u, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f }); - SendRequest(em, conn, 0); - - group.Update(); - - Assert.AreEqual(1, PrepRowCount(em, player), "Still exactly one row (once per run == SourceId presence)."); - Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "No second withdraw."); - } - } - - [Test] - public void Non_Staging_Is_Rejected() - { - var (world, group) = TestWorld.Make("Prep_NonStaging", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.InRoom, ResourceId.Ore, 60); - var (player, conn) = MakePlayer(em, 1); - SendRequest(em, conn, 0); - - group.Update(); - - Assert.AreEqual(0, PrepRowCount(em, player), "No purchase outside Staging."); - Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore), "Ledger untouched outside Staging."); - Assert.AreEqual(0, OpenRequests(em), "Request consumed even when rejected."); - } - } - - [Test] - public void Unknown_Option_Id_Is_Dropped() - { - var (world, group) = TestWorld.Make("Prep_Unknown", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60); - var (player, conn) = MakePlayer(em, 1); - SendRequest(em, conn, 99); // no such row - - group.Update(); - - Assert.AreEqual(0, PrepRowCount(em, player)); - Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Ore)); - Assert.AreEqual(0, OpenRequests(em), "Unknown-id request still consumed."); - } - } - - [Test] - public void Two_Same_Tick_Barely_Enough_Exactly_One_Succeeds() - { - var (world, group) = TestWorld.Make("Prep_Atomic", tick: 100, server: true); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Aether, 25); // enough for exactly ONE 25-Aether buy - var (player, conn) = MakePlayer(em, 1); - SendRequest(em, conn, 2); // Aether 25 -> MeleeDamage - SendRequest(em, conn, 3); // Aether 25 -> Damage - - group.Update(); - - Assert.AreEqual(1, PrepRowCount(em, player), "Exactly one of two same-tick 25-Aether buys succeeds (DR-014 in-loop pre-check)."); - Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir), ResourceId.Aether), "Aether withdrawn once; never negative."); - Assert.AreEqual(0, OpenRequests(em), "Both requests consumed."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta deleted file mode 100644 index 34d922c0c..000000000 --- a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 02cf1a6cbcc3b804191e5e0169c1402d \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs deleted file mode 100644 index 5141e61e8..000000000 --- a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the ready-check spine: (RPC → PlayerReady, - /// Staging/Launching-only) ordered before (the all-ready rising-edge launch, the - /// un-ready countdown abort, the F2 outcome guard, the sub-slot teleport out/home, and the Returning-edge - /// ready-flag clear). Ticks are driven manually through NetworkTime, so the countdown/dwell paths that Play-mode - /// polling races past are pinned deterministically here. - /// - public class ReadyCheckSystemTests - { - const uint T0 = 1000; - - static (World world, SimulationSystemGroup group, Entity dir) MakeWorld() - { - var world = new World("ReadyCheckTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); - em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Staging }); - em.SetComponentData(dir, new RunRuntime { HostSalt = 1u }); - return (world, group, dir); - } - - static void SetTick(World world, uint tick) - { - var em = world.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static Entity MakePlayer(EntityManager em, int networkId) - { - var e = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(GhostOwner), - typeof(RegionTag), typeof(LocalTransform)); - em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); - em.SetComponentData(e, new RegionTag { Region = RegionId.Base }); - em.SetComponentData(e, LocalTransform.Identity); - return e; - } - - static Entity MakeConnection(EntityManager em, int networkId) - { - var e = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(e, new NetworkId { Value = networkId }); - return e; - } - - static void SendToggle(EntityManager em, Entity conn, byte ready) - { - var e = em.CreateEntity(typeof(ReadyToggleRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(e, new ReadyToggleRequest { Ready = ready }); - em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int PendingRequests(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(ReadyToggleRequest)); - return q.CalculateEntityCount(); - } - - [Test] - public void Toggle_SetsReady_AndSoloLaunches_SameTick() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, 1); - var conn = MakeConnection(em, 1); - - SendToggle(em, conn, 1); - group.Update(); - - Assert.AreEqual(1, em.GetComponentData(player).Value, "toggle landed"); - Assert.AreEqual(0, PendingRequests(em), "request consumed"); - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.Launching, info.Lifecycle, "1/1 ready -> rising edge -> Launching"); - Assert.AreNotEqual(0u, info.LaunchTick, "countdown telegraph armed"); - Assert.AreNotEqual(0u, info.RunSeed, "run seeded"); - Assert.GreaterOrEqual(info.RoomCount, 6, "seed-varied length floor"); - Assert.LessOrEqual(info.RoomCount, 10, "seed-varied length cap"); - } - } - - [Test] - public void Toggle_Ignored_MidRun() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, 1); - var conn = MakeConnection(em, 1); - var info = em.GetComponentData(dir); - info.Lifecycle = RunLifecycle.InRoom; - em.SetComponentData(dir, info); - - SendToggle(em, conn, 1); - group.Update(); - - Assert.AreEqual(0, em.GetComponentData(player).Value, "mid-run toggle dropped"); - Assert.AreEqual(0, PendingRequests(em), "request still consumed"); - } - } - - [Test] - public void PartialReady_DoesNotLaunch() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1); - MakePlayer(em, 2); - var conn1 = MakeConnection(em, 1); - - SendToggle(em, conn1, 1); - group.Update(); - - Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle, - "1/2 ready must NOT launch"); - } - } - - [Test] - public void UnReady_DuringCountdown_Aborts() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1); - var conn = MakeConnection(em, 1); - - SendToggle(em, conn, 1); - group.Update(); - Assert.AreEqual(RunLifecycle.Launching, em.GetComponentData(dir).Lifecycle); - - SendToggle(em, conn, 0); // change of heart during the 3-2-1 - group.Update(); - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle, "un-ready aborts the countdown"); - Assert.AreEqual(0u, info.LaunchTick, "telegraph cleared"); - } - } - - [Test] - public void Launch_TeleportsPartyOut_ThenHome_AndClearsReady() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - var player = MakePlayer(em, 1); - var conn = MakeConnection(em, 1); - - SendToggle(em, conn, 1); - group.Update(); // Staging -> Launching (countdown armed at T0) - - SetTick(world, T0 + 200); // past the 180-tick countdown - group.Update(); // enter room 0 (the real traversal, Step 7) - - Assert.AreEqual(RegionId.Expedition, em.GetComponentData(player).Region, - "party region flipped to Expedition"); - Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 999f, - "party teleported to the expedition room origin (sub-slot 0 at +1000)"); - Assert.AreEqual(1f, em.GetComponentData(player).Scale, 1e-4f, - "Scale preserved through the teleport (never FromPosition)"); - Assert.AreEqual(RunLifecycle.InRoom, em.GetComponentData(dir).Lifecycle, "room 0 active"); - - // Simulate the all-left abort (disconnect edge): drop the player's region externally. - em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); - SetTick(world, T0 + 260); - group.Update(); // InRoom -> Returning (abort, no credit) - SetTick(world, T0 + 320); - group.Update(); // Returning: teleport home + clear ready flags -> StagingStaging - - Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "back home"); - Assert.Less(em.GetComponentData(player).Position.x, 100f, "position restored to base"); - Assert.AreEqual(0, em.GetComponentData(player).Value, "ready flag cleared on return"); - var run = em.GetComponentData(dir); - Assert.AreEqual(run.RunEpoch, run.LastBankedRunEpoch, "terminal bank latch fired once"); - Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); - } - } - - - } -} diff --git a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta deleted file mode 100644 index 28ba10407..000000000 --- a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 046f63edcabb72548843c84fcd99dda1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs deleted file mode 100644 index cd64c72b6..000000000 --- a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for (the Step-6 successor of the retired - /// ZoneEnemyDirectorSystem). Pins: the per-RoomEpoch reseed sized by ZoneEnemyMath on the room's DifficultyEpoch; - /// spawns at the ACTIVE sub-slot origin carrying the full tag stack (RegionTag{Expedition} + ZoneEnemyTag + - /// RoomTag) with baked Scale preserved; the Boss room's single scaled boss; the MaxAlive pack-fit wait; and the - /// ExpeditionObjective Cleared/Idle latch written above the early-returns. - /// - public class RoomEnemyDirectorSystemTests - { - const uint Seed = 777u; - const uint T0 = 500; - - static (World world, SimulationSystemGroup group, Entity runDir, Entity zoneDir) MakeWorld( - int currentRoom, int currentNodeId, byte activeSubSlot, int maxAlive = 10) - { - var world = new World("RoomEnemyTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - - var map = RunMapMath.Generate(Seed); - var runDir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective)); - em.SetComponentData(runDir, new RunInfo - { - Lifecycle = RunLifecycle.InRoom, - CurrentRoom = currentRoom, - RoomCount = map.LayerCount, - }); - em.SetComponentData(runDir, new RunRuntime - { - RunSeed = Seed, - RoomEpoch = 1, - CurrentNodeId = currentNodeId, - ActiveSubSlot = activeSubSlot, - }); - - var grunt = MakeEnemyPrefab(em); - var charger = MakeEnemyPrefab(em); - var zoneDir = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState)); - em.SetComponentData(zoneDir, new ZoneEnemyDirector - { - MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10, - GruntsPerWave = 4, ChargersPerWave = 1, SwarmerPackSize = 3, ClusterTightRadius = 1.5f, RewardOre = 25, - }); - var buf = em.AddBuffer(zoneDir); - buf.Add(new ZoneEnemyPrefab { Prefab = grunt }); - buf.Add(new ZoneEnemyPrefab { Prefab = charger }); - return (world, group, runDir, zoneDir); - } - - static Entity MakeEnemyPrefab(EntityManager em) - { - var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag), typeof(Health)); - em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it - em.SetComponentData(e, new Health { Current = 50f, Max = 50f }); - em.AddComponent(e); - return e; - } - - static MixBands Bands() => new MixBands { GruntBase = 4, ChargerBase = 1 }; - - static int Alive(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag)); - return q.CalculateEntityCount(); - } - - static void AdvanceTick(EntityManager em, uint tick) - { - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - [Test] - public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin() - { - var (world, group, runDir, zoneDir) = MakeWorld(currentRoom: 0, currentNodeId: RunMap.NodeId(0, 0), activeSubSlot: 0); - using (world) - { - var em = world.EntityManager; - var map = RunMapMath.Generate(Seed); - var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount); - int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands()); - - group.Update(); // seeds; the landing grace holds the first slot (demo polish) - Assert.AreEqual(slots, em.GetComponentData(zoneDir).RemainingToSpawn, - "grace: nothing spawns on the seed tick"); - AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1); - group.Update(); // grace elapsed -> the first slot drips - - var zs = em.GetComponentData(zoneDir); - Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1"); - Assert.AreEqual(slots - 1, zs.RemainingToSpawn, "wave sized by ZoneEnemyMath on the room's DifficultyEpoch"); - Assert.AreEqual(1, Alive(em), "first slot drip-spawned"); - - var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RoomTag), typeof(RegionTag), typeof(LocalTransform)); - Assert.AreEqual(1, q.CalculateEntityCount(), "spawn carries the FULL tag stack (Zone + Room + Region)"); - var xfs = q.ToComponentDataArray(Allocator.Temp); - var regs = q.ToComponentDataArray(Allocator.Temp); - var rooms = q.ToComponentDataArray(Allocator.Temp); - Assert.AreEqual(RegionId.Expedition, regs[0].Region); - Assert.AreEqual(0, rooms[0].Room); - Assert.AreEqual(1f, xfs[0].Scale, 1e-4f, "baked Scale preserved"); - float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0); - Assert.LessOrEqual(math.distance(xfs[0].Position.xz, origin.xz), 14f + 0.01f, - "ring-spawned around the ACTIVE sub-slot origin"); - xfs.Dispose(); regs.Dispose(); rooms.Dispose(); q.Dispose(); // dispose BEFORE the worldispose(); - } - } - - [Test] - public void BossRoom_SpawnsSingleScaledBoss() - { - var map = RunMapMath.Generate(Seed); - int bossLayer = map.LayerCount - 1; - var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1)); - using (world) - { - var em = world.EntityManager; - - group.Update(); // seed tick (the landing grace holds the boss) - AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1); - group.Update(); // grace elapsed -> the boss spawns - - var zs = em.GetComponentData(zoneDir); - Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned"); - Assert.AreEqual(1, Alive(em), "exactly one boss"); - - var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(Health), typeof(LocalTransform)); - var hps = q.ToComponentDataArray(Allocator.Temp); - var xfs = q.ToComponentDataArray(Allocator.Temp); - Assert.AreEqual(50f * Tuning.BossHealthMultiplier, hps[0].Max, 1e-3f, "boss health scaled"); - Assert.AreEqual(Tuning.BossScaleMultiplier, xfs[0].Scale, 1e-3f, "boss visual scale bumped"); - hps.Dispose(); xfs.Dispose(); q.Dispose(); // dispose BEFORE the world (a using-var here outlives it); - } - } - - [Test] - public void Objective_ClearedLatch_AndIdleOutsideRooms() - { - var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0); - using (world) - { - var em = world.EntityManager; - - // Fabricate a fully-spawned, fully-dead wave for the CURRENT room epoch. - em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 0, SpawnCounter = 5 }); - group.Update(); - Assert.AreEqual(ExpeditionObjectiveState.Cleared, em.GetComponentData(runDir).State, - "fully-spawned + zero-alive latches Cleared for the seeded epoch"); - - var info = em.GetComponentData(runDir); - info.Lifecycle = RunLifecycle.Staging; - em.SetComponentData(runDir, info); - group.Update(); - Assert.AreEqual(ExpeditionObjectiveState.Idle, em.GetComponentData(runDir).State, - "no active room -> Idle (objective still written above the early-return)"); - } - } - - [Test] - public void MaxAlive_PackFitWaits_WithoutConsumingTheSlot() - { - var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0, maxAlive: 1); - using (world) - { - var em = world.EntityManager; - // One zone enemy already alive fills the cap. - var blocker = em.CreateEntity(typeof(ZoneEnemyTag)); - // Pre-seed so the tick goes straight to the drip branch. - em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 2, NextSpawnTick = 0 }); - - group.Update(); - - var zs = em.GetComponentData(zoneDir); - Assert.AreEqual(1, Alive(em), "cap full -> nothing spawned"); - Assert.AreEqual(2, zs.RemainingToSpawn, "the slot WAITS (not consumed) until the pack fits"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta deleted file mode 100644 index 8bc94335d..000000000 --- a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0a5bf96c5c42e5240a8db55fc3e2a01a \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs deleted file mode 100644 index 00d96236f..000000000 --- a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for (the Step-5 successor of the retired - /// ExpeditionFieldSystem + its teardown regression). Pins: exactly one scatter per RoomEpoch (int-equality - /// reseed), the run-wide scarcity budget flooring + spend-down, RoomTag stamping + baked-Scale preservation + - /// in-shape placement at the active sub-slot origin, and the Staging defensive sweep that kills room ghosts while - /// UNTAGGED entities (the old base-field-survives regression, now structural) are untouched. - /// - public class RoomFieldSystemTests - { - const uint Seed = 777u; - - static (World world, SimulationSystemGroup group, Entity dir, Entity spawnerE, Entity prefab) MakeWorld( - int nodeBudget) - { - var world = new World("RoomFieldTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - var map = RunMapMath.Generate(Seed); - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); - em.SetComponentData(dir, new RunInfo - { - Lifecycle = RunLifecycle.InRoom, - CurrentRoom = 0, - RoomCount = map.LayerCount, - }); - em.SetComponentData(dir, new RunRuntime - { - RunSeed = Seed, - RoomEpoch = 1, - CurrentNodeId = RunMap.NodeId(0, 0), - ActiveSubSlot = 0, - NodeBudgetRemaining = nodeBudget, - }); - - // Node ghost prefab: Scale=2 pins the WithPosition (never FromPosition) preservation. - var prefab = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); - em.SetComponentData(prefab, new LocalTransform { Position = float3.zero, Rotation = quaternion.identity, Scale = 2f }); - em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f }); - em.AddComponent(prefab); - - // Spawner singleton with the runtime state PRE-attached (skips the one-shot attach tick). - var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner), typeof(RoomFieldState)); - em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = prefab, Count = 99, Radius = 0f }); - - return (world, group, dir, spawnerE, prefab); - } - - static int LiveNodes(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag)); - return q.CalculateEntityCount(); - } - - [Test] - public void Spawns_OncePerRoomEpoch_PlanCount_TaggedInShape_ScalePreserved() - { - var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); - using (world) - { - var em = world.EntityManager; - var map = RunMapMath.Generate(Seed); - var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount); - int expected = math.min(plan.NodeCount, 12); - float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0); - - group.Update(); - Assert.AreEqual(expected, LiveNodes(em), "one room's plan-count scatter"); - - using (var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag), typeof(LocalTransform))) - { - var tags = q.ToComponentDataArray(Allocator.Temp); - var xfs = q.ToComponentDataArray(Allocator.Temp); - for (int i = 0; i < tags.Length; i++) - { - Assert.AreEqual(0, tags[i].Room, "stamped with the active room index"); - Assert.AreEqual(2f, xfs[i].Scale, 1e-4f, "baked Scale preserved (WithPosition, never FromPosition)"); - Assert.IsTrue(RoomLayoutMath.ContainsPoint(plan.ShapeId, origin, xfs[i].Position), - "scattered inside the room shape at the sub-slot-0 origin"); - Assert.GreaterOrEqual(xfs[i].Position.x, 900f, "placed at the EXPEDITION origin, not the base"); - } - tags.Dispose(); - xfs.Dispose(); - } - - Assert.AreEqual(12 - expected, em.GetComponentData(dir).NodeBudgetRemaining, - "budget spent down by exactly the scattered count"); - - group.Update(); // same RoomEpoch — must NOT scatter again - Assert.AreEqual(expected, LiveNodes(em), "int-equality reseed: one scatter per RoomEpoch"); - } - } - - [Test] - public void Budget_FloorsSpawnCount_AndExhausts() - { - var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 1); - using (world) - { - var em = world.EntityManager; - - group.Update(); - Assert.AreEqual(1, LiveNodes(em), "budget of 1 floors the room to a single node"); - Assert.AreEqual(0, em.GetComponentData(dir).NodeBudgetRemaining); - - // Advance to the next room with a DRY budget — nothing more may spawn. - var run = em.GetComponentData(dir); - run.RoomEpoch = 2; - run.CurrentNodeId = RunMap.NodeId(1, 0); - run.ActiveSubSlot = 1; - em.SetComponentData(dir, run); - var info = em.GetComponentData(dir); - info.CurrentRoom = 1; - em.SetComponentData(dir, info); - - group.Update(); - Assert.AreEqual(1, LiveNodes(em), "a dry budget spawns nothing (scarcity holds run-wide)"); - } - } - - [Test] - public void StagingSweep_KillsRoomGhosts_SparesUntagged() - { - var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); - using (world) - { - var em = world.EntityManager; - group.Update(); - Assert.Greater(LiveNodes(em), 0, "room content exists"); - - // An untagged node (e.g. the base mining field) must be structurally untouchable. - var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); - em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20f, 0f, 0f))); - - var info = em.GetComponentData(dir); - info.Lifecycle = RunLifecycle.Staging; - em.SetComponentData(dir, info); - - group.Update(); - Assert.AreEqual(0, LiveNodes(em), "Staging sweep cleared every room ghost"); - Assert.IsTrue(em.Exists(baseNode), "untagged entities survive (the old base-field regression, structural now)"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta deleted file mode 100644 index f2b727a77..000000000 --- a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9b72710f2e3958f4a827cb3befcc8850 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs deleted file mode 100644 index f5f2aa9ea..000000000 --- a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Mathematics; - -namespace ProjectM.Tests -{ - /// - /// Pure-function tests for — resolving a map node into a and - /// scattering points within a room's shape. Pins the plan mapping, the depth/type difficulty ramp, the per-type - /// node density, and (the swept-scatter safety net) that every scattered point lies within its shape footprint. - /// - public class RoomLayoutMathTests - { - [Test] - public void Plan_CopiesNodeFields_AndResolvesArchetype() - { - var node = new RunMapNode - { - RoomType = RoomTypeId.Reward, - Biome = RoomBiomeId.Cavern, - ShapeId = RoomShapeId.Wide, - NextMask = 1, - }; - var plan = RoomLayoutMath.Plan(node, layer: 3, roomCount: 8); - Assert.AreEqual(RoomTypeId.Reward, plan.RoomType); - Assert.AreEqual(RoomBiomeId.Cavern, plan.Biome); - Assert.AreEqual(RoomShapeId.Wide, plan.ShapeId); - Assert.AreEqual(RoomLayoutMath.ShapeRadius(RoomShapeId.Wide), plan.Radius); - Assert.AreEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward), plan.NodeCount); - Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(3, RoomTypeId.Reward), plan.DifficultyEpoch); - } - - [Test] - public void DifficultyEpoch_DeeperIsHarder_EliteAndBossBump() - { - Assert.AreEqual(1, RoomLayoutMath.DifficultyEpoch(0, RoomTypeId.Combat), "layer 0 floors at 1"); - Assert.Greater(RoomLayoutMath.DifficultyEpoch(5, RoomTypeId.Combat), - RoomLayoutMath.DifficultyEpoch(2, RoomTypeId.Combat), "deeper is harder"); - Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 2, - RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Elite), "Elite +2"); - Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 3, - RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Boss), "Boss +3"); - } - - [Test] - public void BaseNodeCount_RewardDense_BossMinimal() - { - Assert.Greater(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward), - RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), "reward rooms are resource-dense"); - Assert.AreEqual(1, RoomLayoutMath.BaseNodeCount(RoomTypeId.Boss), "boss room is minimal"); - Assert.GreaterOrEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), 1); - } - - [Test] - public void ShapeRadius_AllShapesPositive() - { - for (byte s = 0; s < RoomShapeId.Count; s++) - Assert.Greater(RoomLayoutMath.ShapeRadius(s), 0f, $"shape {s}"); - } - - [Test] - public void ScatterInShape_AlwaysWithinShapeFootprint() - { - var center = new float3(1000f, 1f, 5f); // offset origin (expedition region) + nonzero Y preserved - for (byte shape = 0; shape < RoomShapeId.Count; shape++) - { - var rng = new Random(9871u + shape); - for (int i = 0; i < 500; i++) - { - float3 p = RoomLayoutMath.ScatterInShape(shape, center, i, 500, ref rng); - Assert.AreEqual(center.y, p.y, 1e-4f, $"shape {shape}: Y preserved"); - Assert.IsTrue(RoomLayoutMath.ContainsPoint(shape, center, p), - $"shape {shape}: scattered point {p} escaped the footprint"); - } - } - } - - [Test] - public void ScatterInShape_Deterministic_ForSameSeedSequence() - { - var center = new float3(0f, 0f, 0f); - var a = new Random(4242u); - var b = new Random(4242u); - for (int i = 0; i < 50; i++) - { - float3 pa = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref a); - float3 pb = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref b); - Assert.AreEqual(pa.x, pb.x, 1e-6f); - Assert.AreEqual(pa.z, pb.z, 1e-6f); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta deleted file mode 100644 index 671f0f611..000000000 --- a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 12aadace80f53cd46a8b2699d232f888 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs deleted file mode 100644 index 7d9b2af1d..000000000 --- a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Entities; - -namespace ProjectM.Tests -{ - /// - /// Pins the room-scoped teardown contract (): destroying room i kills ONLY - /// room-i entities — the other room survives (the DR-031/DR-040 cross-room-wipe regression, load-bearing for the - /// ping-pong sub-slot handoff where two rooms transiently coexist), untagged entities are untouched, and each - /// entity is destroyed at most once (single-visit ⇒ no double-destroy Playback throw). - /// - public class RoomTeardownTests - { - static Entity MakeRoomEntity(EntityManager em, byte room, bool asNode) - { - // Mimic real room content: some entities look like resource nodes, some like zone enemies — the - // teardown must be type-agnostic (RoomTag is the only contract). - var e = asNode - ? em.CreateEntity(typeof(RoomTag), typeof(ResourceNode)) - : em.CreateEntity(typeof(RoomTag), typeof(ZoneEnemyTag)); - em.SetComponentData(e, new RoomTag { Room = room }); - return e; - } - - [Test] - public void DestroyRoom_KillsOnlyThatRoom_SparesOtherRoomAndUntagged() - { - using var world = new World("RoomTeardownTest"); - var em = world.EntityManager; - - for (int i = 0; i < 3; i++) MakeRoomEntity(em, 0, asNode: i % 2 == 0); // room 0: 3 entities - for (int i = 0; i < 2; i++) MakeRoomEntity(em, 1, asNode: i % 2 == 0); // room 1: 2 entities - var untagged = em.CreateEntity(typeof(ResourceNode)); // e.g. a base-field node - - using var roomQuery = em.CreateEntityQuery(typeof(RoomTag)); - var ecb = new EntityCommandBuffer(Allocator.Temp); - int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0); - ecb.Playback(em); - ecb.Dispose(); - - Assert.AreEqual(3, destroyed, "exactly room-0's entities were queued"); - using var remaining = em.CreateEntityQuery(typeof(RoomTag)); - var tags = remaining.ToComponentDataArray(Allocator.Temp); - Assert.AreEqual(2, tags.Length, "room 1 survives intact"); - for (int i = 0; i < tags.Length; i++) - Assert.AreEqual(1, tags[i].Room, "every survivor belongs to room 1"); - tags.Dispose(); - Assert.IsTrue(em.Exists(untagged), "untagged (non-room) entities are never touched"); - } - - [Test] - public void DestroyRoom_EmptyRoom_IsANoOp() - { - using var world = new World("RoomTeardownTest2"); - var em = world.EntityManager; - MakeRoomEntity(em, 1, asNode: true); - - using var roomQuery = em.CreateEntityQuery(typeof(RoomTag)); - var ecb = new EntityCommandBuffer(Allocator.Temp); - int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0); // room 0 has nothing - ecb.Playback(em); - ecb.Dispose(); - - Assert.AreEqual(0, destroyed); - using var remaining = em.CreateEntityQuery(typeof(RoomTag)); - Assert.AreEqual(1, remaining.CalculateEntityCount(), "room 1 untouched"); - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta deleted file mode 100644 index 4d81f38f5..000000000 --- a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 95327306dceec754aa635447fc9d04d4 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs deleted file mode 100644 index ba0b18c4c..000000000 --- a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; -using System.Collections.Generic; - -namespace ProjectM.Tests -{ - /// - /// Validation matrix for — the co-op route-pick receiver. Pins: a correctly - /// stamped pick latches (the review's non-maskable acceptance criterion — a validation bug here silently - /// degrades to the grace auto-pick and a linear game); the first-commit latch under two same-tick picks; the - /// re-meaned run-identity stale-reject ((uint)ForRunEpoch == RunSeed); the layer stale-reject; index bounds; - /// the N3 base-region sender reject; the closed-gate reject; and that requests are ALWAYS consumed. - /// - public class RouteSelectSystemTests - { - const uint Seed = 999u; - const int GateLayer = 2; - readonly List _worlds = new(); - - [TearDown] - public void Cleanup() - { - foreach (var w in _worlds) if (w.IsCreated) w.Dispose(); - _worlds.Clear(); - } - - static (World world, SimulationSystemGroup group, Entity dir) MakeGateWorld(byte lifecycle = RunLifecycle.RouteSelect) - { - var world = new World("RouteSelectTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); - em.SetComponentData(dir, new RunInfo - { - Lifecycle = lifecycle, - CurrentRoom = GateLayer, - RunSeed = Seed, - RouteOptionCount = 2, - RouteOpt0Col = 0, - RouteOpt1Col = 2, - }); - em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 3 }); - return (world, group, dir); - } - - static Entity MakePlayer(EntityManager em, int networkId, byte region) - { - var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag)); - em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); - em.SetComponentData(e, new RegionTag { Region = region }); - return e; - } - - static void SendPick(EntityManager em, int networkId, byte optionIndex, int forSeed, int forLayer) - { - var conn = em.CreateEntity(typeof(NetworkId)); - em.SetComponentData(conn, new NetworkId { Value = networkId }); - var req = em.CreateEntity(typeof(RouteSelectRequest), typeof(ReceiveRpcCommandRequest)); - em.SetComponentData(req, new RouteSelectRequest { OptionIndex = optionIndex, ForRunEpoch = forSeed, ForLayer = forLayer }); - em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); - } - - static int PendingRequests(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(RouteSelectRequest)); - return q.CalculateEntityCount(); - } - - [Test] - public void ValidPick_Latches_WithTrueServerEpoch() - { - var (world, group, dir) = MakeGateWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1, RegionId.Expedition); - SendPick(em, 1, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); - - group.Update(); - - var cmd = em.GetComponentData(dir); - Assert.AreEqual(1, cmd.HasPick, "a correctly-stamped pick MUST latch (non-maskable criterion)"); - Assert.AreEqual(1, cmd.OptionIndex, "the picked option"); - Assert.AreEqual(3, cmd.ForRunEpoch, "stamped from the TRUE server epoch, never the client echo"); - Assert.AreEqual(0, PendingRequests(em), "request consumed"); - } - } - - [Test] - public void TwoSameTickPicks_FirstWins() - { - var (world, group, dir) = MakeGateWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1, RegionId.Expedition); - MakePlayer(em, 2, RegionId.Expedition); - SendPick(em, 1, optionIndex: 0, forSeed: (int)Seed, forLayer: GateLayer); // created first -> wins - SendPick(em, 2, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); - - group.Update(); - - var cmd = em.GetComponentData(dir); - Assert.AreEqual(1, cmd.HasPick, "exactly one commit"); - Assert.AreEqual(0, cmd.OptionIndex, "the FIRST accepted pick wins (in-place latch, DR-014)"); - Assert.AreEqual(0, PendingRequests(em), "both requests consumed"); - } - } - - [Test] - public void Rejects_WrongSeed_WrongLayer_OutOfRange_BaseSender_ClosedGate() - { - // Wrong run-identity token (a stale pick from the previous run). - var (w1, g1, d1) = MakeGateWorld(); - _worlds.Add(w1); - MakePlayer(w1.EntityManager, 1, RegionId.Expedition); - SendPick(w1.EntityManager, 1, 0, forSeed: (int)Seed + 1, forLayer: GateLayer); - g1.Update(); - Assert.AreEqual(0, w1.EntityManager.GetComponentData(d1).HasPick, "wrong seed rejected"); - Assert.AreEqual(0, PendingRequests(w1.EntityManager)); - - // Wrong layer (a pick from the previous gate of the SAME run). - var (w2, g2, d2) = MakeGateWorld(); - _worlds.Add(w2); - MakePlayer(w2.EntityManager, 1, RegionId.Expedition); - SendPick(w2.EntityManager, 1, 0, (int)Seed, forLayer: GateLayer - 1); - g2.Update(); - Assert.AreEqual(0, w2.EntityManager.GetComponentData(d2).HasPick, "stale layer rejected"); - - // Option index out of the published range. - var (w3, g3, d3) = MakeGateWorld(); - _worlds.Add(w3); - MakePlayer(w3.EntityManager, 1, RegionId.Expedition); - SendPick(w3.EntityManager, 1, optionIndex: 2, (int)Seed, GateLayer); // count is 2 -> max index 1 - g3.Update(); - Assert.AreEqual(0, w3.EntityManager.GetComponentData(d3).HasPick, "out-of-range rejected"); - - // Base-region sender (N3): a home-bound joiner cannot commit the party's route. - var (w4, g4, d4) = MakeGateWorld(); - _worlds.Add(w4); - MakePlayer(w4.EntityManager, 1, RegionId.Base); - SendPick(w4.EntityManager, 1, 0, (int)Seed, GateLayer); - g4.Update(); - Assert.AreEqual(0, w4.EntityManager.GetComponentData(d4).HasPick, "base sender rejected (N3)"); - - // Gate closed (mid-room): the pick is dropped, never queued. - var (w5, g5, d5) = MakeGateWorld(lifecycle: RunLifecycle.InRoom); - _worlds.Add(w5); - MakePlayer(w5.EntityManager, 1, RegionId.Expedition); - SendPick(w5.EntityManager, 1, 0, (int)Seed, GateLayer); - g5.Update(); - Assert.AreEqual(0, w5.EntityManager.GetComponentData(d5).HasPick, "closed gate rejected"); - Assert.AreEqual(0, PendingRequests(w5.EntityManager), "request still consumed"); - } - - [Test] - public void AlreadyLatched_LaterPickIgnored() - { - var (world, group, dir) = MakeGateWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1, RegionId.Expedition); - em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 3, ForLayer = GateLayer }); - SendPick(em, 1, optionIndex: 1, (int)Seed, GateLayer); - - group.Update(); - - var cmd = em.GetComponentData(dir); - Assert.AreEqual(0, cmd.OptionIndex, "an already-latched gate ignores later picks"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta deleted file mode 100644 index 069868ae2..000000000 --- a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c6b6345371289f64188653851f25d8d1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs deleted file mode 100644 index f2be0782c..000000000 --- a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs +++ /dev/null @@ -1,271 +0,0 @@ -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 -{ - /// - /// Plain-Entities EditMode tests for 's Step-7 linear traversal: the objective - /// Cleared edge tears the room down AT RoomReward ENTRY and the next room spawns only on the advance (the - /// teardown-before-spawn empty-tick invariant), the ping-pong sub-slot flip + RoomEpoch bump + teleport, the boss - /// terminal, and the CLEAR-GATED once-per-RunEpoch bank (boss-clear credits Charge/RunsCompleted/retaliation + - /// save; an abort banks ONLY the honest depth high-water — D-F3/F7/C7). - /// - public class RunDirectorTraversalTests - { - const uint Seed = 777u; - const uint T0 = 2000; - - static (World world, SimulationSystemGroup group, Entity dir, Entity player) MakeMidRunWorld( - int currentRoom, out RunMap map) - { - var world = new World("TraversalTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - - map = RunMapMath.Generate(Seed); - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), - typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(SaveRequest)); - em.SetComponentData(dir, new RunInfo - { - Lifecycle = RunLifecycle.InRoom, - CurrentRoom = currentRoom, - RoomCount = map.LayerCount, - RunSeed = Seed, - }); - em.SetComponentData(dir, new RunRuntime - { - RunSeed = Seed, - RunEpoch = 1, - RoomEpoch = currentRoom + 1, - CurrentNodeId = RunMap.NodeId(currentRoom, 0), - ActiveSubSlot = (byte)(currentRoom & 1), - RoomsClearedThisRun = currentRoom, // rooms before this one were cleared - }); - - // Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it. - var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), - typeof(LocalTransform), typeof(RunParticipant)); - em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); - em.SetComponentData(player, LocalTransform.Identity); - return (world, group, dir, player); - } - - static void SetTick(World world, uint tick) - { - var em = world.EntityManager; - var q = em.CreateEntityQuery(typeof(NetworkTime)); - em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); - q.Dispose(); - } - - static void MarkCleared(EntityManager em, Entity dir) => - em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 }); - - // DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick). - static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir) - { - em.SetComponentData(dir, new PortalCommand { HasInteract = 1 }); - group.Update(); - } - - -static int RoomEntities(EntityManager em) - { - var q = em.CreateEntityQuery(typeof(RoomTag)); - int n = q.CalculateEntityCount(); - q.Dispose(); - return n; - } - - [Test] - public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump() - { - var (world, group, dir, player) = MakeMidRunWorld(0, out var map); - using (world) - { - var em = world.EntityManager; - var node0 = em.CreateEntity(typeof(RoomTag)); - em.SetComponentData(node0, new RoomTag { Room = 0 }); - - MarkCleared(em, dir); - group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here) - Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData(dir).Lifecycle); - Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window"); - Assert.AreEqual(1, em.GetComponentData(dir).RoomsClearedThisRun, "honest depth counter"); - - group.Update(); // RoomReward -> RoomExplore (loot window; portal up) - Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData(dir).Lifecycle); - Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore"); - - PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate - var gateInfo = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate"); - Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)"); - Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published"); - - byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1); - byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col - : pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col; - em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); - - group.Update(); // RouteSelect -> InRoom room 1 at the PICKED column - - var info = em.GetComponentData(dir); - var run = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); - Assert.AreEqual(1, info.CurrentRoom); - Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column"); - Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped"); - Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed"); - Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published"); - Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType); - Assert.AreEqual(0, em.GetComponentData(dir).HasPick, "latch consumed"); - Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance"); - Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 1499f, "party teleported (+1500)"); - } - } - - [Test] - public void BossClear_Returns_AndBanksExactlyOnce_ClearGated() - { - RunMap map0; - var (world, group, dir, player) = MakeMidRunWorld(0, out map0); - using (world) - { - var em = world.EntityManager; - int bossLayer = map0.LayerCount - 1; - var info0 = em.GetComponentData(dir); - info0.CurrentRoom = bossLayer; - em.SetComponentData(dir, info0); - var run0 = em.GetComponentData(dir); - run0.CurrentNodeId = map0.BossNodeId; - run0.ActiveSubSlot = (byte)(bossLayer & 1); - run0.RoomsClearedThisRun = bossLayer; - em.SetComponentData(dir, run0); - - MarkCleared(em, dir); - group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists) - group.Update(); // RoomReward -> RoomExplore - PortalAdvance(em, group, dir); // portal -> Returning (boss cleared) - group.Update(); // Returning: bank + teleport home -> Staging - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle); - Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "party home"); - var meta = em.GetComponentData(dir); - Assert.AreEqual(1, meta.RunsCompleted, "run completed"); - Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared"); - Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested"); - Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated"); - - group.Update(); - group.Update(); - Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted); - } - } - - [Test] - public void Abort_BanksDepthOnly_NoWinCredit() - { - var (world, group, dir, player) = MakeMidRunWorld(2, out var map); - using (world) - { - var em = world.EntityManager; - // All expedition players gone mid-room-2 (rooms 0-1 cleared) -> abort. - em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); - - group.Update(); // InRoom -> Returning (abort) - group.Update(); // Returning: depth-only bank -> Staging - - Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); - var meta = em.GetComponentData(dir); - Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit"); - Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan"); - Assert.AreEqual(0, em.GetComponentData(dir).Pending, "no save spam on abort"); - } - } - - [Test] - public void RouteGate_PickBeatsSameTickGrace() - { - var (world, group, dir, player) = MakeMidRunWorld(0, out var map); - using (world) - { - var em = world.EntityManager; - MarkCleared(em, dir); - group.Update(); - group.Update(); // -> RoomExplore - PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed) - - var gate = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle); - byte pickIdx = (byte)(gate.RouteOptionCount - 1); - byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col; - - em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); - SetTick(world, T0 + 100000); - group.Update(); - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); - Assert.AreEqual(pickedCol, info.CurrentCol, "the accepted pick beats the same-tick grace expiry"); - } - } - - [Test] - public void RouteGate_GraceAutoPicksLowestOption() - { - var (world, group, dir, player) = MakeMidRunWorld(0, out var map); - using (world) - { - var em = world.EntityManager; - MarkCleared(em, dir); - group.Update(); - group.Update(); // -> RoomExplore - PortalAdvance(em, group, dir); // -> RouteSelect - - var gate = em.GetComponentData(dir); - byte lowestCol = gate.RouteOpt0Col; - SetTick(world, T0 + 100000); - group.Update(); - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the AFK backstop advances the run"); - Assert.AreEqual(lowestCol, info.CurrentCol, "deterministic lowest-index reachable auto-pick"); - } - } - - [Test] - public void RouteGate_Abort_ClosesGateOnTheEdge() - { - var (world, group, dir, player) = MakeMidRunWorld(0, out var map); - using (world) - { - var em = world.EntityManager; - MarkCleared(em, dir); - group.Update(); - group.Update(); // -> RoomExplore - PortalAdvance(em, group, dir); // -> RouteSelect - Assert.Greater((int)em.GetComponentData(dir).RouteOptionCount, 0); - - em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left - group.Update(); // RouteSelect -> Returning (abort) - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle); - Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3)"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta deleted file mode 100644 index 4327f15db..000000000 --- a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0b3e546718a6f9846a320e56d5b1acb4 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs deleted file mode 100644 index 16ac9c41c..000000000 --- a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; -using Unity.Collections; - -namespace ProjectM.Tests -{ - /// - /// Pure-function tests for — the deterministic branching run-map generator. Pins - /// determinism (server + client must regenerate the SAME map), the structural invariants the traversal + route - /// choice rely on (run length, single landing, single Boss terminal, an all-Elite gate so every path fights an - /// Elite, full reachability, no all-Reward interior layer), and the reachable-options enumeration. No ECS world. - /// - public class RunMapMathTests - { - // Sweep a spread of seeds so the structural invariants hold generation-wide, not for one lucky map. - static uint[] Seeds() - { - var s = new uint[64]; - for (int i = 0; i < s.Length; i++) s[i] = (uint)(i * 2654435761u + 1u); - return s; - } - - [Test] - public void Generate_Deterministic_SameSeedSameMap() - { - foreach (var seed in Seeds()) - { - var a = RunMapMath.Generate(seed); - var b = RunMapMath.Generate(seed); - Assert.AreEqual(a.LayerCount, b.LayerCount, $"seed {seed}: LayerCount"); - for (int layer = 0; layer < a.LayerCount; layer++) - { - Assert.AreEqual(a.Width(layer), b.Width(layer), $"seed {seed}: width L{layer}"); - for (int col = 0; col < a.Width(layer); col++) - Assert.IsTrue(a.Node(layer, col).Equals(b.Node(layer, col)), - $"seed {seed}: node ({layer},{col}) differs between regenerations"); - } - } - } - - [Test] - public void Generate_RunLength_InSixToTenInclusive() - { - foreach (var seed in Seeds()) - { - int L = RunMapMath.Generate(seed).LayerCount; - Assert.GreaterOrEqual(L, 6, $"seed {seed}"); - Assert.LessOrEqual(L, RunMap.MaxLayers, $"seed {seed}"); - } - } - - [Test] - public void Generate_Layer0_IsSingleCombatLanding() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - Assert.AreEqual(1, m.Width(0), $"seed {seed}: landing width"); - Assert.AreEqual(RoomTypeId.Combat, m.Node(0, 0).RoomType, $"seed {seed}: landing type"); - } - } - - [Test] - public void Generate_LastLayer_IsSingleBossTerminal() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - int last = m.LayerCount - 1; - Assert.AreEqual(1, m.Width(last), $"seed {seed}: boss width"); - Assert.AreEqual(RoomTypeId.Boss, m.Node(last, 0).RoomType, $"seed {seed}: boss type"); - Assert.AreEqual(0, m.Node(last, 0).NextMask, $"seed {seed}: boss is a terminal (NextMask 0)"); - } - } - - [Test] - public void Generate_SecondLastLayer_IsAllElite_GuaranteesElitePerPath() - { - // The only layer feeding the Boss is L-2; every start->boss path traverses it. All-Elite there ⇒ every - // path fights >= 1 Elite before the boss. - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - int gate = m.LayerCount - 2; - for (int col = 0; col < m.Width(gate); col++) - Assert.AreEqual(RoomTypeId.Elite, m.Node(gate, col).RoomType, - $"seed {seed}: gate node ({gate},{col}) must be Elite"); - } - } - - [Test] - public void Generate_ExactlyOneTerminal_IsTheBoss() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - int terminals = 0; - for (int layer = 0; layer < m.LayerCount; layer++) - for (int col = 0; col < m.Width(layer); col++) - if (m.Node(layer, col).NextMask == 0) terminals++; - Assert.AreEqual(1, terminals, $"seed {seed}: exactly one terminal node (the boss)"); - } - } - - [Test] - public void Generate_EveryNonBossNode_HasAnOutEdge() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - for (int layer = 0; layer < m.LayerCount - 1; layer++) - for (int col = 0; col < m.Width(layer); col++) - Assert.AreNotEqual(0, m.Node(layer, col).NextMask, - $"seed {seed}: node ({layer},{col}) has no out-edge"); - } - } - - [Test] - public void Generate_AllNodesReachableFromRoot() - { - foreach (var seed in Seeds()) - Assert.IsTrue(RunMapMath.AllNodesReachable(RunMapMath.Generate(seed)), - $"seed {seed}: a node was stranded (unreachable from the root)"); - } - - [Test] - public void Generate_InteriorLayerWidths_AreTwoOrThree() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - for (int layer = 1; layer < m.LayerCount - 1; layer++) - { - int w = m.Width(layer); - Assert.IsTrue(w == 2 || w == 3, $"seed {seed}: interior layer {layer} width {w} not in {{2,3}}"); - } - } - } - - [Test] - public void Generate_NoInteriorLayerIsAllReward() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - for (int layer = 1; layer < m.LayerCount - 1; layer++) - { - bool anyNonReward = false; - for (int col = 0; col < m.Width(layer); col++) - if (m.Node(layer, col).RoomType != RoomTypeId.Reward) anyNonReward = true; - Assert.IsTrue(anyNonReward, $"seed {seed}: interior layer {layer} is entirely Reward"); - } - } - } - - [Test] - public void ReachableOptions_MatchNextMask_AndStayInNextWidth() - { - foreach (var seed in Seeds()) - { - var m = RunMapMath.Generate(seed); - for (int layer = 0; layer < m.LayerCount - 1; layer++) - for (int col = 0; col < m.Width(layer); col++) - { - int n = RunMapMath.ReachableOptions(m, layer, col, out FixedList32Bytes cols); - Assert.Greater(n, 0, $"seed {seed}: node ({layer},{col}) offered no options"); - Assert.AreEqual(n, cols.Length); - byte mask = m.Node(layer, col).NextMask; - int wn = m.Width(layer + 1); - for (int i = 0; i < cols.Length; i++) - { - Assert.Less(cols[i], (byte)wn, $"seed {seed}: option out of next-layer width"); - Assert.AreNotEqual(0, mask & (1 << cols[i]), $"seed {seed}: option not set in NextMask"); - } - } - } - } - - [Test] - public void ReachableOptions_BossLayer_ReturnsNone() - { - var m = RunMapMath.Generate(12345u); - int n = RunMapMath.ReachableOptions(m, m.LayerCount - 1, 0, out FixedList32Bytes cols); - Assert.AreEqual(0, n); - Assert.AreEqual(0, cols.Length); - } - - [Test] - public void Hash_IsDeterministic_AndSensitiveToInputs() - { - Assert.AreEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(7u, 3u)); - Assert.AreEqual(RunMapMath.Hash(1u, 2u, 3u), RunMapMath.Hash(1u, 2u, 3u)); - Assert.AreNotEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(3u, 7u), "order-sensitive"); - Assert.AreNotEqual(RunMapMath.Hash(1u), RunMapMath.Hash(2u)); - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta deleted file mode 100644 index df85b0c79..000000000 --- a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ed13d85c05c6fc8469ae19c07647e32e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs deleted file mode 100644 index bc8af82ea..000000000 --- a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Regression pins for the two post-impl-review fixes on : - /// (1) a dead-respawned (base-region) player's stale neither holds the - /// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal - /// pickable through the next fight); (2) room advances teleport ONLY s — - /// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner - /// stays at base (spec §2.2 closed party). - /// - public class RunRosterRegressionTests - { - const uint Seed = 777u; - const uint T0 = 2000; - - static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map) - { - var world = new World("RosterTest"); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - 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) }); - - map = RunMapMath.Generate(Seed); - var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), - typeof(RouteCommand), typeof(MetaCounters), typeof(SaveRequest)); - return (world, group, dir); - } - - static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending) - { - var player = participant - ? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), - typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant)) - : em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), - typeof(LocalTransform), typeof(BoonOffer)); - em.SetComponentData(player, new RegionTag { Region = region }); - em.SetComponentData(player, LocalTransform.Identity); - em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 }); - return player; - } - - [Test] - public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit() - { - var (world, group, dir) = MakeWorld(out var map); - using (world) - { - var em = world.EntityManager; - em.SetComponentData(dir, new RunInfo - { - Lifecycle = RunLifecycle.RoomReward, - CurrentRoom = 1, - CurrentCol = 0, - RoomCount = map.LayerCount, - RunSeed = Seed, - }); - em.SetComponentData(dir, new RunRuntime - { - RunSeed = Seed, - RunEpoch = 1, - RoomEpoch = 2, - // Grace far in the future: ONLY the all-picked path can advance this tick. - RewardGraceTick = TickUtil.NonZero(T0 + 100000), - }); - - var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already - var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge - - group.Update(); - - var info = em.GetComponentData(dir); - Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle, - "a base-region player's stale Pending must not hold the reward gate"); - Assert.AreEqual(0, em.GetComponentData(deadAtBase).Pending, - "the stale offer is stripped on the gate exit"); - Assert.AreEqual(0, em.GetComponentData(alive).Pending, - "no offer survives the gate"); - - } - } - - [Test] - public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase() - { - var (world, group, dir) = MakeWorld(out var map); - using (world) - { - var em = world.EntityManager; - em.SetComponentData(dir, new RunInfo - { - Lifecycle = RunLifecycle.RouteSelect, - CurrentRoom = 0, - CurrentCol = 0, - RoomCount = map.LayerCount, - RunSeed = Seed, - RouteOptionCount = 1, - RouteOpt0Col = 0, - }); - em.SetComponentData(dir, new RunRuntime - { - RunSeed = Seed, - RunEpoch = 1, - RoomEpoch = 1, - RouteGraceTick = TickUtil.NonZero(T0 + 100000), - }); - em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 }); - - var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on - var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted - var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home - - group.Update(); - - var info = em.GetComponentData(dir); - Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run"); - Assert.GreaterOrEqual(em.GetComponentData(inRoom).Position.x, 1000f, - "the in-room participant rides the advance"); - Assert.GreaterOrEqual(em.GetComponentData(deadAtBase).Position.x, 1000f, - "a dead-respawned participant is re-conscripted onto the new room"); - Assert.AreEqual(0f, em.GetComponentData(lateJoiner).Position.x, - "a non-participant late joiner is never yanked into the fight"); - Assert.AreEqual(RegionId.Base, em.GetComponentData(lateJoiner).Region, - "the late joiner stays in the Base relevancy bucket"); - - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta deleted file mode 100644 index c7b67d27e..000000000 --- a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 31b988b0527c7c247ad1976aab05d470 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs b/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs deleted file mode 100644 index 78b93f9f4..000000000 --- a/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -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 -{ - /// - /// MC-2 system tests for the EnemyAISystem SPITTER pass (server-only, plain SimulationSystemGroup). Covers the - /// headline ranged mechanic end-to-end: an in-band, ready Spitter commits a telegraphed wind-up then on elapse - /// spawns a spit carrying the FIRING Spitter's Region (fired from EXPEDITION so a dropped Region copy — which would - /// leave the prefab default 0 = Base — fails the assertion), aimed at the target. The HOLD-RANGE gate (DR-041) is - /// pinned by negative tests: a Spitter ADVANCING from out of band does NOT telegraph; a cornered Spitter fires - /// point-blank. The discriminator partition (no double-move) is asserted DIRECTLY (the wind-up value alone can't - /// prove it — the Spitter pass runs last and overwrites it). Soft-fail over the concurrent cap = short retry, no - /// full-cooldown burn. Plain-Entities world, faked NetworkTime + a SpitterProjectilePrefab singleton; the prefab - /// entity is Prefab-tagged so it is excluded from the live-spit count and cloned (minus the tag) on Instantiate. - /// - public class SpitterBrainTests - { - static void SetTick(World w, uint tick) - { - var em = w.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); - em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static (World, SimulationSystemGroup) AiWorld(uint tick) - { - var w = new World("SpitterBrain"); - var g = w.GetOrCreateSystemManaged(); - g.AddSystemToUpdateList(w.GetOrCreateSystem()); - g.SortSystems(); - w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - SetTick(w, tick); - return (w, g); - } - - static Entity MakeSpitPrefab(EntityManager em, float range = 16f) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(float3.zero)); - em.AddComponentData(e, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Damage = 0f, Range = range, Region = 0 }); - em.AddComponent(e); // excluded from the live-spit query; stripped on Instantiate - return e; - } - - static void SetSpitSingleton(EntityManager em, Entity prefab, int maxLive) - { - var s = em.CreateEntity(typeof(SpitterProjectilePrefab)); - em.SetComponentData(s, new SpitterProjectilePrefab { Prefab = prefab, MaxLiveProjectiles = maxLive }); - } - - static Entity MakeSpitter(EntityManager em, float3 pos, byte region, int windupTicks = 1, int cooldown = 60) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponent(e); - em.AddComponentData(e, new EnemyStats { MoveSpeed = 4f, AttackRange = 1.5f, AttackDamage = 8f, AttackCooldownTicks = cooldown }); - em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0u }); - em.AddComponentData(e, new KnockbackState { Dir = default, Speed = 0f, UntilTick = 0u }); - em.AddComponentData(e, new AttackWindup { WindUpUntilTick = 0u }); - em.AddComponentData(e, new SpitterState { PreferredRange = 9f, RangeTolerance = 1.5f, ProjectileSpeed = 11f, CorneredRange = 3f, WindupTicks = windupTicks, NextShotTick = 0u }); - em.AddComponentData(e, new RegionTag { Region = region }); - return e; - } - - static void MakePlayer(EntityManager em, float3 pos, byte region) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); - em.AddComponentData(e, new RegionTag { Region = region }); - em.AddComponent(e); - } - - static int CountSpits(EntityManager em) - { - using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); - return q.CalculateEntityCount(); - } - - [Test] - public void Spitter_InBand_CommitsThenFires_SpitCarriesFiringRegion() - { - var (w, g) = AiWorld(200); - using (w) - { - var em = w.EntityManager; - var prefab = MakeSpitPrefab(em, range: 16f); - SetSpitSingleton(em, prefab, maxLive: 24); - // Fire from EXPEDITION (!=0): a dropped Region copy would leave 0 (Base) and fail the region assert. - MakePlayer(em, new float3(0, 1, 0), RegionId.Expedition); - var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Expedition, windupTicks: 1); // distance == PreferredRange -> in-band - - g.Update(); // tick 200: in-band + ready -> commit the telegraph wind-up to 201 - Assert.AreEqual(TickUtil.NonZero(201u), em.GetComponentData(spitter).WindUpUntilTick, - "an in-band, ready Spitter commits a wind-up of SpitterState.WindupTicks (the partition itself is asserted in Spitter_IsExcludedFromGruntAndChargerPasses)"); - Assert.AreEqual(0, CountSpits(em), "no spit yet — still telegraphing the dodge window"); - - SetTick(w, 202); // the wind-up tick (201) has now elapsed - g.Update(); - Assert.AreEqual(1, CountSpits(em), "the spit fires when the wind-up elapses in-band"); - using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); - var spit = q.GetSingleton(); - Assert.AreEqual(RegionId.Expedition, spit.Region, "the spit carries the FIRING Spitter's region (Expedition!=0 -> a dropped copy fails this)"); - Assert.Less(spit.Direction.x, 0f, "aimed back toward the player at the origin"); - Assert.AreEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, "the wind-up is cleared after firing"); - } - } - - [Test] - public void Spitter_OutOfBand_DoesNotCommitWindup() - { - var (w, g) = AiWorld(200); - using (w) - { - var em = w.EntityManager; - MakePlayer(em, new float3(0, 1, 0), RegionId.Base); - var spitter = MakeSpitter(em, new float3(40, 1, 0), RegionId.Base, windupTicks: 1); // dist 40 >> PreferredRange+tol -> advancing - g.Update(); - Assert.AreEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, - "a Spitter ADVANCING from out of band must NOT telegraph/fire (the hold-range gate, DR-041)"); - } - } - - [Test] - public void Spitter_Cornered_CommitsWindupPointBlank() - { - var (w, g) = AiWorld(200); - using (w) - { - var em = w.EntityManager; - MakePlayer(em, new float3(0, 1, 0), RegionId.Base); - var spitter = MakeSpitter(em, new float3(2, 1, 0), RegionId.Base, windupTicks: 5); // dist 2 < CorneredRange 3 -> point-blank - g.Update(); - Assert.AreNotEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, - "a cornered Spitter (target inside CorneredRange) fires point-blank rather than holding fire"); - } - } - - [Test] - public void Spitter_IsExcludedFromGruntAndChargerPasses() - { - var w = new World("SpitterRouting"); - using (w) - { - var em = w.EntityManager; - MakeSpitter(em, new float3(9, 1, 0), RegionId.Base); - // The three EnemyAISystem pass partitions, asserted directly so a regression in any WithNone guard is caught. - using var gruntQ = em.CreateEntityQuery(new EntityQueryDesc - { - All = new[] { ComponentType.ReadOnly() }, - None = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, - }); - using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc - { - All = new[] { ComponentType.ReadOnly() }, - None = new[] { ComponentType.ReadOnly() }, - }); - using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc - { - All = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, - None = new[] { ComponentType.ReadOnly() }, - }); - Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone)"); - Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone)"); - Assert.AreEqual(1, spitterQ.CalculateEntityCount(), "a Spitter IS visited by exactly the Spitter pass"); - } - } - - [Test] - public void Spitter_OverSoftCap_SkipsFire_ShortRetryNoCooldownBurn() - { - var (w, g) = AiWorld(200); - using (w) - { - var em = w.EntityManager; - var prefab = MakeSpitPrefab(em); - SetSpitSingleton(em, prefab, maxLive: 2); - // pre-fill the live-spit pool to the cap (no Prefab tag -> counted by the soft-cap query) - for (int i = 0; i < 2; i++) - { - var s = em.CreateEntity(); - em.AddComponentData(s, LocalTransform.FromPosition(new float3(i, 1, 0))); - em.AddComponentData(s, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Range = 16f, Region = RegionId.Base }); - } - MakePlayer(em, new float3(0, 1, 0), RegionId.Base); - var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Base, windupTicks: 1, cooldown: 60); - - g.Update(); // tick 200: in-band -> commit the wind-up to 201 - SetTick(w, 202); // elapsed - g.Update(); // at the cap -> soft-fail - Assert.AreEqual(2, CountSpits(em), "at the concurrent cap the Spitter does NOT spawn another spit"); - Assert.AreEqual(TickUtil.NonZero(210u), em.GetComponentData(spitter).NextShotTick, - "soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs.meta b/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs.meta deleted file mode 100644 index f19c95375..000000000 --- a/Assets/_Project/Tests/EditMode/SpitterBrainTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2fc1d0d0241a80745a308575f71c701b \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs deleted file mode 100644 index 55b7779d8..000000000 --- a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only — the RPC handler - /// that applies deposit/withdraw ops to the shared storage container's replicated StorageEntry buffer. - /// A bare world with a SharedStorageContainer singleton (carrying the buffer) plus synthetic - /// StorageOpRequest + ReceiveRpcCommandRequest entities exercises the handler. The system plays - /// its ECB back immediately (Temp allocator), so the handled request entity is destroyed within the single - /// group update. Mirrors HealthApplyDamageSystemTests. Locks the deposit/withdraw/drop-row behaviour before - /// the Stage-C const refactor and any later storage-model changes. - /// - public class StorageOpReceiveSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - return (world, group); - } - - static Entity MakeContainer(EntityManager em, ushort itemId, int count) - { - var e = em.CreateEntity(typeof(SharedStorageContainer)); - var buf = em.AddBuffer(e); - if (itemId != 0) - buf.Add(new StorageEntry { ItemId = itemId, Count = count }); - return e; - } - - static void MakeRequest(EntityManager em, byte op, ushort itemId, int count) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new StorageOpRequest { Op = op, ItemId = itemId, Count = count }); - em.AddComponentData(e, default(ReceiveRpcCommandRequest)); - } - - [Test] - public void Withdraw_Decrements_Existing_Row_And_Destroys_Request() - { - var (world, group) = MakeWorld("StorageWithdrawWorld"); - using (world) - { - var em = world.EntityManager; - var container = MakeContainer(em, itemId: 1, count: 100); - MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30); - - group.Update(); - - var buf = em.GetBuffer(container); - Assert.AreEqual(1, buf.Length, "A partial withdraw keeps the row."); - Assert.AreEqual(70, buf[0].Count, "100 - 30 = 70 must remain."); - - using var reqQuery = em.CreateEntityQuery(typeof(StorageOpRequest)); - Assert.AreEqual(0, reqQuery.CalculateEntityCount(), - "The handled request entity must be destroyed by the system's ECB."); - } - } - - [Test] - public void Deposit_Of_New_Item_Appends_A_Row() - { - var (world, group) = MakeWorld("StorageDepositWorld"); - using (world) - { - var em = world.EntityManager; - var container = MakeContainer(em, itemId: 1, count: 100); - MakeRequest(em, StorageOp.Deposit, itemId: 2, count: 20); - - group.Update(); - - var buf = em.GetBuffer(container); - Assert.AreEqual(2, buf.Length, "Depositing a previously-absent item appends a second row."); - int item2 = -1; - for (int i = 0; i < buf.Length; i++) - if (buf[i].ItemId == 2) item2 = buf[i].Count; - Assert.AreEqual(20, item2, "The appended row carries the deposited count."); - } - } - - [Test] - public void Withdraw_Of_Full_Stack_Drops_The_Row() - { - var (world, group) = MakeWorld("StorageWithdrawZeroWorld"); - using (world) - { - var em = world.EntityManager; - var container = MakeContainer(em, itemId: 1, count: 30); - MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30); - - group.Update(); - - var buf = em.GetBuffer(container); - Assert.AreEqual(0, buf.Length, "Withdrawing the whole stack drops the row entirely."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta deleted file mode 100644 index 4e8a07d7b..000000000 --- a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 62eb6ac6dd96837468c04d1d89b39499 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs b/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs deleted file mode 100644 index 0a465e3c1..000000000 --- a/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -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 -{ - /// - /// MC-2 system tests for the SWARMER cluster spawn in the base-siege WaveSystem (fork 4a). A swarmer composition - /// slot must instantiate a whole PACK in one tick (EnemyAIMath.ClusterOffset) while consuming exactly ONE wave - /// SLOT, and MaxAlive must count ENTITIES — a pack that won't fit is DEFERRED (slot kept) rather than partially - /// spawned (the review-flagged slot-vs-entity accounting). Plain-Entities world, server WaveSystem registered - /// directly, faked NetworkTime; the 4-entry [Grunt,Charger,Spitter,Swarmer] roster is Prefab-tagged so the - /// instances (and only the instances) count as live EnemyTag ghosts. - /// - public class SwarmerClusterSpawnTests - { - static void SetTick(World w, uint tick) - { - var em = w.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); - em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static (World, SimulationSystemGroup) WaveWorld(uint tick) - { - var w = new World("SwarmerCluster"); - var g = w.GetOrCreateSystemManaged(); - g.AddSystemToUpdateList(w.GetOrCreateSystem()); - g.SortSystems(); - w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - SetTick(w, tick); - return (w, g); - } - - // Director with a swarmer-only band so slot 0 is unambiguously a swarmer pack. The 4-entry roster aliases - // dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack. - static Entity MakeDirector(EntityManager em, int swarmerSlotBase, int packSize, int maxAlive) - { - // Create the 4 prefab entities FIRST: every em.CreateEntity is a structural change, so a DynamicBuffer - // handle grabbed before them would be invalidated (the bug this ordering avoids). The roster aliases - // dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack. - var prefabs = new Entity[4]; - for (int i = 0; i < 4; i++) - { - var p = em.CreateEntity(); - em.AddComponentData(p, LocalTransform.FromPosition(float3.zero)); - em.AddComponent(p); - em.AddComponent(p); - prefabs[i] = p; - } - var dir = em.CreateEntity(); - em.AddComponentData(dir, new WaveDirector - { - RingRadius = 10f, RingSlots = 8, BaseCount = 0, CountPerWave = 0, - SpawnIntervalTicks = 1, LullTicks = 1, MaxAlive = maxAlive, - ChargerBase = 0, SpitterBase = 0, SwarmerSlotBase = swarmerSlotBase, - ChargerPerEpoch = 0, SpitterPerEpoch = 0, SwarmerSlotPerEpoch = 0, - SwarmerPackSize = packSize, SwarmerPackPerEpoch = 0, ClusterTightRadius = 2.5f, - }); - em.AddComponentData(dir, new WaveState { WaveNumber = 0, Phase = WavePhase.Lull, NextActionTick = 0u, RemainingToSpawn = 0, SpawnCounter = 0 }); - // AddBuffer LAST (after every structural change on dir), then populate with no further structural change. - var buf = em.AddBuffer(dir); - for (int i = 0; i < 4; i++) buf.Add(new WaveEnemyPrefab { Prefab = prefabs[i] }); - return dir; - } - - static int CountEnemies(EntityManager em) - { - using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); - return q.CalculateEntityCount(); - } - - [Test] - public void Swarmer_Slot_SpawnsWholePack_ConsumesOneSlot() - { - var (w, g) = WaveWorld(200); - using (w) - { - var em = w.EntityManager; - var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 12); - g.Update(); // Lull -> start wave: RemainingToSpawn = WaveSlots(1) = 1 swarmer slot - Assert.AreEqual(1, em.GetComponentData(dir).RemainingToSpawn, "one swarmer SLOT this wave"); - g.Update(); // Spawning -> the pack lands in one tick - Assert.AreEqual(4, CountEnemies(em), "the whole pack spawns in a single tick"); - var st = em.GetComponentData(dir); - Assert.AreEqual(1, st.SpawnCounter, "exactly ONE slot consumed for the pack"); - Assert.AreEqual(0, st.RemainingToSpawn, "the swarmer slot is done"); - } - } - - [Test] - public void Swarmer_PackOverMaxAlive_Defers_KeepsSlot() - { - var (w, g) = WaveWorld(200); - using (w) - { - var em = w.EntityManager; - var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 3); // pack(4) > cap(3) - g.Update(); // start wave - g.Update(); // try to spawn -> 0 + 4 > 3 -> defer (don't partially spawn, don't consume the slot) - Assert.AreEqual(0, CountEnemies(em), "a pack that won't fit MaxAlive is NOT partially spawned"); - var st = em.GetComponentData(dir); - Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred"); - Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs.meta b/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs.meta deleted file mode 100644 index caec787c1..000000000 --- a/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 96771355c799615499f6455055b66ca8 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs index 11a85c98d..b72399b72 100644 --- a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs +++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs @@ -24,16 +24,16 @@ namespace ProjectM.Tests void Add() where T : unmanaged, ISystem => group.AddSystemToUpdateList(world.GetOrCreateSystem()); - // RPC-receive systems ordered before the run director - Add(); Add(); Add(); - Add(); Add(); Add(); Add(); - // Run director + the systems ordered around it (the cycle/siege spine is deleted — LANTERN purge) - Add(); Add(); - Add(); Add(); + // RPC-receive systems + the surviving server spine. The 2026-08-07 audit purge removed + // ReadyToggle / RouteSelect / PortalInteract / MetaSpend / BoonApply / PrepPurchase / RunDirector / + // RoomField / RoomEnemyDirector / BoonOffer / BossAI / EnemyProjectile* along with the shell. + Add(); Add(); - // Combat sub-chain in the same group - Add(); Add(); - Add(); Add(); + Add(); + Add(); + Add(); + Add(); + Add(); Assert.DoesNotThrow(() => group.SortSystems(), "A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation."); @@ -43,18 +43,18 @@ namespace ProjectM.Tests [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). + // A cycle in the predicted combat chain's [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). DashTrailDamageSystem and KillRewardSystem were + // removed from this roster with the 2026-08-07 boon purge. using var world = new World("OrderCyclePredicted"); var group = world.GetOrCreateSystemManaged(); void Add() where T : unmanaged, ISystem => group.AddSystemToUpdateList(world.GetOrCreateSystem()); - Add(); Add(); Add(); Add(); + Add(); Add(); Add(); Add(); Add(); Add(); - Add(); Add(); + Add(); // 07-15 facing rework: PlayerAimSystem gained [UpdateAfter(MeleeComboSystem)] (plus the existing // StatRecompute/PlayerDeathState UpdateBefore edges) - co-register the full facing neighborhood so a // cycle in these edges throws here instead of only at Play world-creation. diff --git a/Assets/_Project/Tests/EditMode/TelemetryCountersTests.cs b/Assets/_Project/Tests/EditMode/TelemetryCountersTests.cs index b15f41eae..6831cde00 100644 --- a/Assets/_Project/Tests/EditMode/TelemetryCountersTests.cs +++ b/Assets/_Project/Tests/EditMode/TelemetryCountersTests.cs @@ -11,8 +11,7 @@ namespace ProjectM.Tests /// /// EditMode coverage for the MC-0/MC-1 DevTelemetry counter WIRING (the fun-gate is measured, not argued): /// DashIFrameNegatedHits + DashState.NegatedCount (HealthApplyDamageSystem), DashesWasted (DashSystem - /// window-close edge, server-gated on the DevTelemetry singleton), and ChargerWhiffPunishesLanded - /// (player-sourced hit inside a Charger's StaggerUntilTick window, scored ONCE per window). + /// window-close edge, server-gated on the DevTelemetry singleton). /// public class TelemetryCountersTests { @@ -59,13 +58,6 @@ namespace ProjectM.Tests return e; } - static Entity MakeStaggeredCharger(EntityManager em, uint staggerUntil) - { - var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(LungeState)); - em.SetComponentData(e, new Health { Current = 200f, Max = 200f }); - em.SetComponentData(e, new LungeState { StaggerUntilTick = staggerUntil }); - return e; - } [Test] public void Negation_Increments_DevTelemetry_And_DashState_NegatedCount() @@ -144,62 +136,8 @@ namespace ProjectM.Tests } } - [Test] - public void Player_Hit_On_Staggered_Charger_Scores_Punish_Once() - { - var (world, group) = MakeWorld("TelemPunish", 120, withTelemetry: true); - using (world) - { - var em = world.EntityManager; - var e = MakeStaggeredCharger(em, staggerUntil: 150); // stagger window active at 120 - var dmg = em.GetBuffer(e); - dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // player hit - dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // same drain, same window - - group.Update(); - - Assert.AreEqual(1u, Telemetry(em).ChargerWhiffPunishesLanded, - "A stagger window counts at most ONE punish (ratio to windows-opened stays <= 1)."); - Assert.AreEqual(0u, em.GetComponentData(e).StaggerUntilTick, - "Scoring zeroes StaggerUntilTick (the one-shot)."); - Assert.AreEqual(180f, em.GetComponentData(e).Current, 1e-4f, "Both hits still apply damage."); - } - } - - [Test] - public void NonPlayer_Hit_On_Staggered_Charger_Does_Not_Score() - { - var (world, group) = MakeWorld("TelemPunishTurret", 120, withTelemetry: true); - using (world) - { - var em = world.EntityManager; - var e = MakeStaggeredCharger(em, staggerUntil: 150); - em.GetBuffer(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = 119 }); - - group.Update(); - - Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded, - "Environment/turret damage (SourceNetworkId=-1) never scores a punish."); - Assert.AreEqual(150u, em.GetComponentData(e).StaggerUntilTick, - "The window stays scoreable for a real player hit."); - } - } - - [Test] - public void Expired_Stagger_Window_Does_Not_Score() - { - var (world, group) = MakeWorld("TelemPunishLate", 200, withTelemetry: true); - using (world) - { - var em = world.EntityManager; - var e = MakeStaggeredCharger(em, staggerUntil: 150); // already over at 200 - em.GetBuffer(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 199 }); - - group.Update(); - - Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded, - "A hit after the stagger window elapses is not a punish."); - } - } + // 2026-08-07 audit purge: the three ChargerWhiffPunishesLanded tests are gone with the Charger. They + // exercised HealthApplyDamageSystem's LungeState.StaggerUntilTick branch, which no baked prefab could + // ever reach. The dash-window counters above still cover the telemetry plumbing itself. } } diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs index df6bf65df..a28a42cd4 100644 --- a/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs @@ -5,56 +5,41 @@ namespace ProjectM.Tests { /// /// Pure-function tests for (no ECS world): the deterministic, save-reproducible - /// expedition-wave composition. Pins the lower bound, the per-epoch ramp, and the grunt-heavy -> charger-heavy - /// shift (grunt count held fixed; the per-epoch growth is all chargers). + /// wave size. Pins the lower bound and the per-epoch ramp. + /// + /// 2026-08-07 audit purge: the IsChargerSlot / weighted-composition tests are gone with the 4-kind MixBands + /// model. They were green the whole time while the behaviour they certified could not execute — no prefab + /// carried ChargerAuthoring, so every weighted slot resolved to Grunt at runtime. That false confidence is + /// exactly what the audit flagged. Recover them from git alongside MixBands if the model returns. /// public class ZoneEnemyMathTests { [Test] public void WaveSize_LowerBoundedAtOne() { - Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0, 0), "an occupied expedition always has at least one enemy"); - Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0, 0), "epoch is floored at 1 internally"); + Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0), "an occupied arena always has at least one enemy"); + Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0), "epoch is floored at 1 internally"); } [Test] public void WaveSize_BaselinePlusOnePerEpoch() { - Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 4, 1), "epoch 1: 4 grunts + 1 charger"); - Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 4, 1), "epoch 3: baseline 5 + (3-1) ramp"); + Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 5), "epoch 1 == the baked base count"); + Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 5), "epoch 3: baseline 5 + (3-1) ramp"); } [Test] - public void IsChargerSlot_Epoch1_GruntsFirst_OneChargerLast() + public void WaveSize_NegativeBaseCount_StillFights() { - // epoch 1, G=4 C=1 -> size 5, only the last slot is a charger. - Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 0, 4, 1)); - Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 3, 4, 1)); - Assert.IsTrue(ZoneEnemyMath.IsChargerSlot(1, 4, 4, 1)); + Assert.AreEqual(1, ZoneEnemyMath.WaveSize(1, -4), "a mis-authored negative base count still fights"); } [Test] - public void Composition_GruntCountFixed_ChargerShareGrowsWithEpoch() + public void WaveSize_Deterministic() { - AssertComposition(epoch: 1, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 1); - AssertComposition(epoch: 5, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 5); - } - - static void AssertComposition(int epoch, int grunts, int chargers, int expectGrunts, int expectChargers) - { - int size = ZoneEnemyMath.WaveSize(epoch, grunts, chargers); - int g = 0, c = 0; - for (int slot = 0; slot < size; slot++) - if (ZoneEnemyMath.IsChargerSlot(epoch, slot, grunts, chargers)) c++; else g++; - Assert.AreEqual(expectGrunts, g, $"grunt count at epoch {epoch}"); - Assert.AreEqual(expectChargers, c, $"charger count at epoch {epoch}"); - } - - [Test] - public void IsChargerSlot_Deterministic() - { - for (int slot = 0; slot < 9; slot++) - Assert.AreEqual(ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1), ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1)); + for (int epoch = 1; epoch < 9; epoch++) + Assert.AreEqual(ZoneEnemyMath.WaveSize(epoch, 5), ZoneEnemyMath.WaveSize(epoch, 5), + "same inputs must give the same wave size — a replayed wave is identical"); } } } diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs deleted file mode 100644 index 5c1136e93..000000000 --- a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; - -namespace ProjectM.Tests -{ - /// - /// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot / - /// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test - /// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size + - /// composition is provably controlled where it must be (the fork-4a safety net). - /// - public class ZoneEnemyMixTests - { - static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands - { - GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw, - ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer, - }; - - [Test] - public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands() - { - Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight"); - Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1"); - Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)"); - Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot"); - } - - [Test] - public void KindForSlot_Deterministic() - { - var b = Bands(4, 1, 1, 1, 1, 1, 1); - for (int slot = 0; slot < 30; slot++) - Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)"); - } - - [Test] - public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch() - { - var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow - CountKinds(b, 1, out int g1, out int c1, out int _, out int _); - Assert.AreEqual(4, g1); Assert.AreEqual(1, c1); - CountKinds(b, 5, out int g5, out int c5, out int _, out int _); - Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)"); - } - - [Test] - public void KindForSlot_ParityWithLegacyIsChargerSlot() - { - for (int g = 0; g <= 6; g++) - for (int c = 0; c <= 4; c++) - for (int e = 1; e <= 6; e++) - { - var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer - int size = ZoneEnemyMath.WaveSlots(e, b); - Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}"); - for (int slot = 0; slot < size + 3; slot++) - { - bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c); - bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger; - Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}"); - } - } - } - - [Test] - public void PackSizeForSlot_FixedByDefault_RampsWhenSet() - { - var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1); - Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack"); - Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs"); - var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2); - Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)"); - Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1"); - } - - static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw) - { - g = c = sp = sw = 0; - int size = ZoneEnemyMath.WaveSlots(epoch, b); - for (int slot = 0; slot < size; slot++) - { - byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b); - if (k == ZoneEnemyMath.KindGrunt) g++; - else if (k == ZoneEnemyMath.KindCharger) c++; - else if (k == ZoneEnemyMath.KindSpitter) sp++; - else sw++; - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta deleted file mode 100644 index f70319c1f..000000000 --- a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6853067864d6bc342bac525cdee324f8 \ No newline at end of file