LANTERN purge B2: delete EB-2 turret defense

TurretFireSystem, TurretAuthoring, Turret.prefab, Turret component, turret cap,
B hotkey, HUD Charge chip + out-of-ammo cue, catalog entry, Tuning consts, tests
(-7, 452 green). StructureType.Turret byte + ResourceId.Charge stay reserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:40:12 -07:00
parent a0f6d4a5c4
commit 835dace213
15 changed files with 15 additions and 692 deletions
@@ -13,12 +13,6 @@ namespace ProjectM.Authoring
/// </summary>
public class StructureCatalogAuthoring : MonoBehaviour
{
[Tooltip("Turret structure ghost prefab (TurretAuthoring + GhostAuthoring).")]
public GameObject TurretPrefab;
[Tooltip("Ore cost to build a turret.")]
[Min(0)] public int TurretCostOre = 40; // DR-042 combat pass: was 10 (~1/3 node) -> 40 (~1.3 nodes); a real investment
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
public GameObject WallPrefab;
@@ -39,16 +33,6 @@ namespace ProjectM.Authoring
AddComponent<StructureCatalog>(entity);
var buf = AddBuffer<StructureCatalogEntry>(entity);
if (authoring.TurretPrefab != null)
{
buf.Add(new StructureCatalogEntry
{
Type = StructureType.Turret,
Prefab = GetEntity(authoring.TurretPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Ore,
CostAmount = authoring.TurretCostOre,
});
}
if (authoring.WallPrefab != null)
{
buf.Add(new StructureCatalogEntry
@@ -1,39 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the turret structure ghost prefab (duplicate UpgradePickup.prefab so the ownerless
/// interpolated GhostAuthoringComponent comes free). Bakes <see cref="PlacedStructure"/>{Type=Turret} +
/// <see cref="Turret"/> stats. BuildPlaceSystem stamps Cell + LastProcessedTick at placement.
/// </summary>
public class TurretAuthoring : MonoBehaviour
{
[Min(1f)] public float Range = 10f;
[Min(1)] public int CooldownTicks = 30;
[Min(1f)] public float Damage = 12f;
[Min(1f)] public float MaxHp = 120f;
private class TurretBaker : Baker<TurretAuthoring>
{
public override void Bake(TurretAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
this.AddPlacedStructure(entity, StructureType.Turret);
AddComponent(entity, new Turret
{
Range = authoring.Range,
CooldownTicks = authoring.CooldownTicks,
Damage = authoring.Damage,
});
// EB-1: structures are damageable + destructible (Husks push for them; HealthApplyDamageSystem
// destroys a Destructible at Health<=0). The DamageEvent buffer MUST exist on the archetype or an
// AI/turret strike crashes at ECB playback. NO HitRadius on purpose -> ProjectileDamageSystem (needs
// Health+HitRadius) ignores structures, so player shots never friendly-fire your own turret.
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dfb1e0820cbf41b4bbbe99066dfadd40
@@ -27,7 +27,6 @@ namespace ProjectM.Client
// Dev hotkey fallback: key -> buildable.
static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys =
{
(UnityEngine.InputSystem.Key.B, StructureType.Turret),
(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.
@@ -54,8 +53,6 @@ namespace ProjectM.Client
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 });
/// <summary>EDITOR / execute_code hook: queue a turret placement at a specific cell.</summary>
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
@@ -33,7 +33,6 @@ namespace ProjectM.Client
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
static readonly Color ChargeViolet = new(0.80f, 0.45f, 1f);
static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
@@ -93,7 +92,7 @@ namespace ProjectM.Client
readonly List<VisualElement> _pips = new();
// resources
Label _aetherNum, _oreNum, _bioNum, _chargeNum;
Label _aetherNum, _oreNum, _bioNum;
// build palette + hints
VisualElement _paletteRow, _hintBar, _buildDiscoveryChip;
@@ -384,7 +383,7 @@ namespace ProjectM.Client
}
// ---- Resources (feed palette affordability) ----
int aether = 0, ore = 0, bio = 0, charge = 0;
int aether = 0, ore = 0, bio = 0;
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
@@ -394,25 +393,13 @@ namespace ProjectM.Client
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;
else if (en.ItemId == ResourceId.Charge) charge = en.Count;
}
}
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
_chargeNum.text = charge.ToString();
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
// (Step 11: upgrade-button affordability tint retired with the button.)
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
// broken turret): a dry base during a siege tells the player to build a Fabricator.
if (siege && charge == 0 && !onExpedition)
{
_locationText.text = "TURRETS OUT OF AMMO - build a Fabricator (Ore -> ammo)";
_locationText.style.color = new Color(1f, 0.4f, 0.9f);
}
// ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ----
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) && core.Max > 0)
{
@@ -1026,7 +1013,6 @@ namespace ProjectM.Client
strip.Add(ResourceChip(theme != null ? theme.AetherIcon : null, AetherCyan, "0", out _aetherNum, 26, 20));
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
// (Step 11: the Aether UPGRADE-DMG button was RETIRED with AbilityUpgradeRequest — the choice-of-3
@@ -1318,7 +1304,6 @@ namespace ProjectM.Client
{
switch (type)
{
case StructureType.Turret: return "Turret";
case StructureType.Wall: return "Wall";
case StructureType.Pylon: return "Pylon";
default: return "?";
@@ -48,12 +48,10 @@ namespace ProjectM.Client
public Sprite BioIcon;
[Header("Structure icons")]
public Sprite TurretIcon;
public Sprite WallIcon;
public Sprite PylonIcon;
[Header("Build-ghost preview meshes (authored real-size, ground pivot — BuildSendSystem)")]
public Mesh TurretGhostMesh;
public Mesh WallGhostMesh;
[Header("Build-mode control glyphs")]
@@ -82,7 +80,6 @@ namespace ProjectM.Client
{
switch (type)
{
case StructureType.Turret: return TurretIcon;
case StructureType.Wall: return WallIcon;
case StructureType.Pylon: return PylonIcon;
default: return null;
@@ -94,7 +91,6 @@ namespace ProjectM.Client
{
switch (type)
{
case StructureType.Turret: return TurretGhostMesh;
case StructureType.Wall: return WallGhostMesh;
default: return null;
}
@@ -47,14 +47,10 @@ namespace ProjectM.Server
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
// Derive occupancy from the live structure set (authoritative); also count turrets for the per-base cap.
// Derive occupancy from the live structure set (authoritative).
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
int turretCount = 0;
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
{
occupied.Add(ps.ValueRO.Cell);
if (ps.ValueRO.Type == StructureType.Turret) turretCount++;
}
var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -68,9 +64,7 @@ namespace ProjectM.Server
for (int i = 0; i < catalog.Length; i++)
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
// DR-042 combat pass: cap turrets per base (server-authoritative) so they can't be spammed.
bool turretCapOk = req.StructureType != StructureType.Turret || turretCount < Tuning.TurretCap;
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null && turretCapOk
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
{
var entry = catalog[entryIdx];
@@ -84,7 +78,6 @@ namespace ProjectM.Server
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
occupied.Add(cell);
if (req.StructureType == StructureType.Turret) turretCount++; // keep same-tick turret requests under the cap
var structure = ecb.Instantiate(entry.Prefab);
var xform = m_TransformLookup[entry.Prefab];
@@ -1,130 +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
{
/// <summary>
/// Server-only turret fire (hitscan) — EnemyAISystem reversed. Snapshots living Husks once (entity, planar
/// pos, region); each turret picks the nearest Husk in ITS region within Range and, on a NetworkTick
/// cooldown stored in <see cref="PlacedStructure.NextTick"/>, appends a direct <c>DamageEvent</c>
/// (SourceNetworkId=-1) to it. Reuses HealthApplyDamageSystem (already destroys EnemyTag at HP&lt;=0) — no
/// projectile, no tunnelling, no friendly-fire. Plain server SimulationSystemGroup
/// <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> (the predicted group is OrderFirst → UpdateBefore is
/// ignored); the appended DamageEvent drains next tick (~16ms), consistent with EnemyAISystem. Self-gates:
/// Husks only exist during the Defend wave.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct TurretFireSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<ResourceLedger>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Turret>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var huskEntities = new NativeList<Entity>(Allocator.Temp);
var huskPos = new NativeList<float2>(Allocator.Temp);
var huskRegion = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // LIVING targets only (B3)
{
if (health.ValueRO.Current <= 0f)
continue;
huskEntities.Add(e);
huskPos.Add(xform.ValueRO.Position.xz);
huskRegion.Add(region.ValueRO.Region);
}
if (huskEntities.Length == 0)
{
huskEntities.Dispose(); huskPos.Dispose(); huskRegion.Dispose();
return;
}
// EB-2: resolve the shared ledger ONCE (NEVER GetSingleton<StorageEntry> — a 2nd StorageEntry buffer
// exists on the base container). Turrets withdraw Charge from it sequentially (a finite pool split in
// query order; later turrets soft-fail when it empties).
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (ps, turret, xform, region) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Turret>, RefRO<LocalTransform>, RefRO<RegionTag>>())
{
uint nextRaw = ps.ValueRO.NextTick;
if (nextRaw != 0)
{
var nextTick = new NetworkTick(nextRaw);
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
continue; // still cooling down
}
float2 tp = xform.ValueRO.Position.xz;
byte treg = region.ValueRO.Region;
float rangeSq = turret.ValueRO.Range * turret.ValueRO.Range;
int best = -1;
float bestSq = float.MaxValue;
for (int i = 0; i < huskEntities.Length; i++)
{
if (huskRegion[i] != treg)
continue;
float sq = math.distancesq(huskPos[i], tp);
if (sq <= rangeSq && sq < bestSq)
{
bestSq = sq;
best = i;
}
}
if (best >= 0)
{
// EB-2 felt spend: a shot costs Charge from the shared ledger. Gate BOTH the damage AND the
// cooldown advance on a SUCCESSFUL withdraw — out of Charge = SOFT-FAIL (no shot, no cooldown
// burn, so the turret fires the instant Charge returns). Refund a partial (cost>1 underflow).
int cost = math.max(1, Tuning.TurretChargeCostPerShot);
int got = StorageMath.Withdraw(ledger, ResourceId.Charge, cost);
if (got >= cost)
{
ecb.AppendToBuffer(huskEntities[best], new DamageEvent
{
Amount = turret.ValueRO.Damage,
SourceNetworkId = -1,
SourceTick = TickUtil.NonZero(now),
});
uint cd = (uint)math.max(1, turret.ValueRO.CooldownTicks);
ps.ValueRW.NextTick = TickUtil.NonZero(now + cd);
}
else if (got > 0)
{
StorageMath.Deposit(ledger, ResourceId.Charge, got); // never consume Charge without firing
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
huskEntities.Dispose(); huskPos.Dispose(); huskRegion.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 53cc7669bd6cc5d4e8307b18732897bc
@@ -5,20 +5,19 @@ using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Structure type ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). Turret is the
/// first concrete structure; Harvester/Fabricator/Conveyor are RESERVED now (free) for the M7 automation
/// pillar (production chains) so adding them later is purely additive.
/// 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.
/// </summary>
public static class StructureType
{
public const byte None = 0;
// RETIRED ids — reserved, do not reuse:
public const byte Turret = 1;
// Reserved for M7 automation — do not reuse these codes:
public const byte Harvester = 2;
public const byte Fabricator = 3;
public const byte Conveyor = 4;
// World-pass structural/cosmetic build-out (additive; these byte values keep PlacedStructure.Type's
// [GhostField] serializer identical — no re-bake of existing ghosts). Do NOT reuse 2-4 (M7 automation).
// Live buildables:
public const byte Wall = 5;
public const byte Pylon = 6;
}
@@ -29,10 +28,8 @@ namespace ProjectM.Simulation
/// (a cheap byte for client visual branching); <see cref="Cell"/> is server-only (clients derive it from
/// the replicated LocalTransform via <see cref="BaseGridMath.WorldToCell"/>, so it stays off the wire).
/// <see cref="NextTick"/> / <see cref="LastProcessedTick"/> are server-only raw NetworkTick values
/// (<see cref="TickUtil.NonZero"/>-guarded; 0 = inactive): the Turret reuses <see cref="NextTick"/> as its
/// fire cooldown NOW (not dead weight), and M7 production reuses both as the next-production-tick +
/// deterministic offline catch-up linchpin (produced = floor((now - LastProcessedTick)/period)). These two
/// tick fields are the IDENTITY/TIMING that cannot be reconstructed retroactively, so they are baked now.
/// (<see cref="TickUtil.NonZero"/>-guarded; 0 = inactive), kept for future timed structures (the turret
/// cooldown + production catch-up that used them are retired — LANTERN purge).
/// </summary>
public struct PlacedStructure : IComponentData
{
@@ -42,29 +39,16 @@ namespace ProjectM.Simulation
/// <summary>Occupied grid cell (server-only; clients derive it from LocalTransform).</summary>
public int2 Cell;
/// <summary>Next action tick (server-only; turret cooldown now / next production tick in M7). 0 = inactive.</summary>
/// <summary>Next action tick (server-only). 0 = inactive.</summary>
public uint NextTick;
/// <summary>Last tick this structure was processed (server-only; M7 offline-catch-up baseline). Stamped at spawn.</summary>
/// <summary>Last tick this structure was processed (server-only). Stamped at spawn.</summary>
public uint LastProcessedTick;
}
/// <summary>
/// A buildable defense turret (the first structure). Hitscan: <c>TurretFireSystem</c> applies a direct
/// <c>DamageEvent</c> to the nearest in-range living Husk on cooldown (reusing
/// <see cref="PlacedStructure.NextTick"/>) — reuses HealthApplyDamageSystem, no projectile/friendly-fire.
/// </summary>
public struct Turret : IComponentData
{
public float Range;
public int CooldownTicks;
public float Damage;
}
/// <summary>
/// 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). M7 adds a recipe
/// column to this element additively (the catalog is baked, not replicated → no format break).
/// (prefab baked via GetEntity, NEVER inside a blob — blobs don't remap entity refs).
/// </summary>
public struct StructureCatalogEntry : IBufferElementData
{
@@ -85,3 +69,4 @@ namespace ProjectM.Simulation
/// </summary>
public struct RuntimePlacedTag : IComponentData { }
}
@@ -56,18 +56,6 @@ namespace ProjectM.Simulation
/// baked telegraph can't drift from the server windup.</summary>
public const int ChargerWindupTicks = 30;
// ---- Base defense (EB-2) ----
/// <summary>EB-2: Charge (turret munition) consumed per turret shot, withdrawn from the global ledger. A
/// turret with 0 Charge SOFT-FAILS (no shot, no cooldown advance). A ledger-fed Fabricator mints Charge from
/// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary>
public const int TurretChargeCostPerShot = 1;
/// <summary>Max turrets buildable per base (server-enforced in BuildPlaceSystem; the client preview goes red at
/// the cap). Turrets are a deliberate fortress investment, not spammable — paired with the 40-Ore build cost.</summary>
public const int TurretCap = 6;
// ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ----
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save