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
-204
View File
@@ -1,204 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &2057690259992313649
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4449724123933675757}
- component: {fileID: 3818890329194429404}
- component: {fileID: 6553709043537316589}
m_Layer: 0
m_Name: Model
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &4449724123933675757
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2057690259992313649}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3572766465862231365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3818890329194429404
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2057690259992313649}
m_Mesh: {fileID: -8131118695690612189, guid: 472a8342ece31c14f9d53ff7119b7857, type: 3}
--- !u!23 &6553709043537316589
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2057690259992313649}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 4cee7a5983b187943adc589b9cb1f084, type: 2}
- {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 1794795016809289889}
- component: {fileID: 9049467567705961987}
m_Layer: 9
m_Name: Turret
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4449724123933675757}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!114 &1794795016809289889
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: dfb1e0820cbf41b4bbbe99066dfadd40, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.TurretAuthoring
Range: 10
CooldownTicks: 30
Damage: 12
MaxHp: 120
--- !u!65 &9049467567705961987
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 0.8, y: 1.2, z: 0.8}
m_Center: {x: 0, y: 0.6, z: 0}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 5459c9edea89bd94fa6f5043ae00eb40
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -1,219 +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
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="TurretFireSystem"/> (hitscan defense turret).
/// A bare world is seeded with a <c>NetworkTime</c> singleton, a <see cref="ResourceLedger"/> Charge pool (the
/// turret's EB-2 ammo), a turret (PlacedStructure + Turret + RegionTag + LocalTransform) and Husks (Health +
/// EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The system snapshots living Husks, fires at
/// the nearest in-range one in its OWN region on the <c>PlacedStructure.NextTick</c> cooldown — but ONLY when
/// it can withdraw <see cref="Tuning.TurretChargeCostPerShot"/> Charge from the ledger; out of Charge =
/// soft-fail (no shot, no cooldown burn, so it fires the instant Charge returns). These tests pin range
/// filtering, region gating, the wrap-safe cooldown, and the EB-2 Charge spend / soft-fail.
/// </summary>
public class TurretFireSystemTests
{
// Range/region/cooldown tests never need to run dry; the spend tests pass an explicit small pool.
const int AmpleCharge = 1_000_000;
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(
string name, uint serverTick, int charge = AmpleCharge)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<TurretFireSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
var buf = em.AddBuffer<StorageEntry>(ledger);
if (charge > 0)
buf.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = charge });
return (world, group, ledger);
}
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 Entity MakeTurret(EntityManager em, float3 pos, byte region, float range, int cooldown, float damage)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponentData(e, new PlacedStructure { Type = StructureType.Turret, NextTick = 0 });
em.AddComponentData(e, new Turret { Range = range, CooldownTicks = cooldown, Damage = damage });
return e;
}
static Entity MakeHusk(EntityManager em, float3 pos, byte region, float health)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponentData(e, new Health { Current = health, Max = health });
em.AddComponent<EnemyTag>(e);
em.AddBuffer<DamageEvent>(e);
return e;
}
static int LedgerCharge(EntityManager em, Entity ledger)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Charge) return buf[i].Count;
return 0;
}
[Test]
public void Turret_Fires_At_InRange_Husk_In_Its_Region()
{
var (world, group, _) = MakeWorld("TurretFireWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var turret = MakeTurret(em, new float3(5, 1, 5), RegionId.Base, range: 20f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(10, 1, 10), RegionId.Base, health: 50f);
group.Update();
var dmg = em.GetBuffer<DamageEvent>(husk);
Assert.AreEqual(1, dmg.Length, "An in-range, same-region Husk takes exactly one turret hit.");
Assert.AreEqual(10f, dmg[0].Amount, 1e-4f, "DamageEvent carries the turret's damage.");
Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Turret damage uses the -1 (world) source id.");
Assert.AreNotEqual(0u, em.GetComponentData<PlacedStructure>(turret).NextTick,
"The cooldown tick is stamped after firing.");
}
}
[Test]
public void Turret_Ignores_Husk_Out_Of_Range()
{
var (world, group, _) = MakeWorld("TurretRangeWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 5f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(100, 1, 0), RegionId.Base, health: 50f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length, "A Husk beyond Range takes no hit.");
}
}
[Test]
public void Turret_Ignores_Husk_In_A_Different_Region()
{
var (world, group, _) = MakeWorld("TurretRegionWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(2, 1, 2), RegionId.Expedition, health: 50f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length,
"A Husk in a different region is never targeted (region gating).");
}
}
[Test]
public void Turret_Respects_Cooldown_Then_Fires_Again()
{
var (world, group, _) = MakeWorld("TurretCooldownWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 5000f);
group.Update(); // fires at tick 200, NextTick -> 230
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "Fires on the first ready tick.");
SetServerTick(world, 210);
group.Update(); // 210 < 230 -> still cooling down
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "No second shot while cooling down.");
SetServerTick(world, 240);
group.Update(); // 240 >= 230 -> ready again
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(husk).Length, "Fires again once the cooldown elapses.");
}
}
// ---- EB-2: turret ammo (Charge) spend + soft-fail ----
[Test]
public void Turret_Out_Of_Charge_Soft_Fails_No_Shot_No_Cooldown_Burn()
{
var (world, group, ledger) = MakeWorld("TurretNoChargeWorld", serverTick: 200, charge: 0);
using (world)
{
var em = world.EntityManager;
var turret = MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length,
"With an empty Charge pool the turret cannot fire (soft-fail).");
Assert.AreEqual(0u, em.GetComponentData<PlacedStructure>(turret).NextTick,
"A soft-failed shot burns no cooldown — the turret stays ready for the instant Charge returns.");
Assert.AreEqual(0, LedgerCharge(em, ledger), "No Charge is consumed when no shot is fired.");
}
}
[Test]
public void Turret_Consumes_One_Charge_Per_Shot()
{
var (world, group, ledger) = MakeWorld("TurretSpendWorld", serverTick: 200, charge: 5);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f);
group.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "A funded turret fires.");
Assert.AreEqual(5 - Tuning.TurretChargeCostPerShot, LedgerCharge(em, ledger),
"Exactly TurretChargeCostPerShot Charge is withdrawn for the shot.");
}
}
[Test]
public void Two_Turrets_Share_A_Finite_Charge_Pool_Second_Soft_Fails()
{
// One Charge, two turrets, one Husk: the first turret (query/creation order) spends the last Charge and
// fires; the second withdraws 0 and soft-fails. The Husk takes exactly one hit and the pool empties.
var (world, group, ledger) = MakeWorld("TurretSharedPoolWorld", serverTick: 200, charge: 1);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(-2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
MakeTurret(em, new float3(2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(0, 1, 0), RegionId.Base, health: 500f);
group.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length,
"A single Charge funds exactly one of the two turrets this tick.");
Assert.AreEqual(0, LedgerCharge(em, ledger), "The shared Charge pool is drained to zero.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a86a8f9715eff4c45817718443589cdb