Slice Combat Depth (MC-3 + wiring + review fixes): Spitter aim-line + player-hit punch, rigged enemies, in-band gate (DR-041)

Completes the Combat Depth slice on top of the MC-2 server spine (56cf60cce):

MC-3 impact juice (client, observe-only):
- 7 FeelConfig fields + ResetDefaults; magnitude-scaled player-dealt-hit camera
  PunchFov on the enemy-Health-decrease edge (camera-only hit-stop, never timeScale).
- Spitter Kind==2 aim-LANE telegraph (BuildLaneMesh) — reads baked SpitterState
  client-side, falls back to a fixed length. True freeze + material flash deferred.

Content / wiring:
- SpitterProjectilePrefabAuthoring (the SpitterProjectilePrefab singleton).
- Both directors rebuilt to a 4-entry KIND-INDEXED roster [Grunt,Charger,Spitter,
  Swarmer] + mix/MaxAlive config + the SpitterProjectileConfig singleton in the subscene.
- Real rigged models: EnemySpitter (re-skinned Kaiju, ranged poker) + EnemySwarmerUndead
  (Undead-Werewolf, fast/low-HP); grunt/charger keep Werewolf/ChargerMuscle. EnemySpit =
  ownerless interpolated ghost (no Health, no collider).

Post-impl adversarial review fixes (wf_febdcfdb-665):
- [MED] in-band fire gate: the Spitter committed its telegraph from ANY range (fired while
  advancing from far). Now commits only when sInBand || sCornered (gives CorneredRange a
  real read site) — a Spitter out-of-band holds fire and repositions.
- [LOW] EnemyProjectileDamageSystem early-returns on !ServerTick.IsValid (sibling parity).
- [LOW] EnemyAuthoring bake-time guard: errors if a prefab composes both Charger+Spitter
  (would match zero AI passes -> never move).
- [LOW] tests: Spitter brain fires from Expedition (kills the Base==0 region false-green);
  a direct partition-exclusion test replaces the order-masked claim; added out-of-band +
  cornered negative tests.

388/388 EditMode green + two Play smokes (clean boot, fire, swept-hit, region, server==
client; rigged Kaiju spitter bakes + fires with zero console errors). Accepted as-is
(documented in DR-041): global spit soft-cap, co-op punch attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:08:59 -07:00
parent 56cf60cce3
commit e32dadbc66
20 changed files with 5907 additions and 16 deletions
+180
View File
@@ -0,0 +1,180 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &803404312977859260
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 544753127400808733}
- component: {fileID: 8071270979468917903}
- component: {fileID: 4362678855125273864}
m_Layer: 0
m_Name: Model
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &544753127400808733
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
serializedVersion: 2
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0.4, z: 0}
m_LocalScale: {x: 1.3, y: 1.3, z: 1.3}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3572766465862231365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &8071270979468917903
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
m_Mesh: {fileID: 4300000, guid: a52e2d101bf32694d9012060774e97fd, type: 3}
--- !u!23 &4362678855125273864
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
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: 71e41e43b459a244abaf5acca76b89ee, 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: 6800460489587750746}
m_Layer: 0
m_Name: EnemySpit
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: 544753127400808733}
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 &6800460489587750746
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: ff79c8fbcacb8c34faad37d59836b5ac, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyProjectileAuthoring
Speed: 11
Damage: 8
Range: 16
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8a13ef653e5266741b0896084f74038f
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f29273bdfc85a8b49a4e16719e9a2568
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b0921d4cb0eedd349a257759a157653a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -61,6 +61,10 @@ namespace ProjectM.Authoring
byte kind = ZoneEnemyMath.KindGrunt;
byte windup = (byte)Tuning.AttackWindupTicks;
var spitter = GetComponent<SpitterAuthoring>();
// 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<ChargerAuthoring>() != 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<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; }
@@ -0,0 +1,36 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the <see cref="SpitterProjectilePrefab"/> subscene singleton. Place ONE on a GameObject in
/// the gameplay subscene; the server EnemyAISystem Spitter pass reads it via <c>GetSingleton</c> 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.
/// </summary>
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<SpitterProjectilePrefabAuthoring>
{
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,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ce5223c5fd56694c81991e1bb1232de
@@ -217,7 +217,15 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isEnemy) ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
if (isEnemy)
{
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
// (saturate(delta / RefDamage)) so a chip reads soft and a heavy connect snaps.
// Camera-only hit-stop (NEVER Time.timeScale); keys on the enemy Health-decrease edge.
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
}
}
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
@@ -739,7 +747,20 @@ namespace ProjectM.Client
}
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
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<SpitterState>(entity))
{
var ss = SystemAPI.GetComponent<SpitterState>(entity);
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
}
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
}
else BuildDangerMesh(go.GetComponent<MeshFilter>().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;
@@ -892,6 +913,33 @@ namespace ProjectM.Client
mesh.RecalculateBounds();
}
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
// already rotated to the enemy facing, so +Z is "toward the locked target".
static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity)
{
float a = 0.18f + 0.62f * intensity;
var verts = new Vector3[4]
{
new Vector3(-halfWidth, 0f, 0.2f),
new Vector3( halfWidth, 0f, 0.2f),
new Vector3(-halfWidth, 0f, length),
new Vector3( halfWidth, 0f, length),
};
var cols = new Color[4]
{
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a * 0.12f),
new Color(1f, 1f, 1f, a * 0.12f),
};
var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) };
var tris = new int[6] { 0, 2, 1, 1, 2, 3 };
mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
mesh.RecalculateBounds();
}
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
{
if (ps == null) return;
@@ -34,6 +34,22 @@ namespace ProjectM.Client
/// <summary>Milliseconds the FOV kick eases back to base.</summary>
public static float HitStopDurationMs;
// ---- MC-3: player-dealt-hit punch (magnitude-scaled) + deferred enemy flash ----
/// <summary>Min FOV kick (deg) when the LOCAL player lands a hit on an enemy (chip damage).</summary>
public static float HitStopFovKickMin;
/// <summary>Max FOV kick (deg) on a heavy player-dealt hit (delta >= HitStopRefDamage).</summary>
public static float HitStopFovKickMax;
/// <summary>Reserved cap for the (deferred) true freeze-frame, in fixed frames.</summary>
public static int HitStopMaxFrames;
/// <summary>Damage delta that saturates the player-dealt punch to HitStopFovKickMax.</summary>
public static float HitStopRefDamage;
/// <summary>Tint for the (deferred) enemy material hit-flash — exposed now, wired in the ShaderGraph slice.</summary>
public static Color HitFlashColor;
/// <summary>Duration (ms) of the deferred enemy hit-flash.</summary>
public static float HitFlashDurationMs;
/// <summary>Master gate for the (deferred) true freeze-frame hit-stop. FALSE for v1 (camera-punch only).</summary>
public static bool HitStopFreezeEnabled;
// ---- Feature 1/2: death camera punch ----
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
public static float PlayerDeathShake;
@@ -103,6 +119,13 @@ namespace ProjectM.Client
HitSfxVolume = 0.70f;
HitStopFovKick = 1.5f;
HitStopDurationMs = 90f;
HitStopFovKickMin = 0.6f;
HitStopFovKickMax = 2.2f;
HitStopMaxFrames = 3;
HitStopRefDamage = 30f;
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
HitFlashDurationMs = 80f;
HitStopFreezeEnabled = false;
// Feature 1/2 death
PlayerDeathShake = 0.50f;
@@ -432,7 +432,13 @@ namespace ProjectM.Server
else
{
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
// 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) && (sTargetEntity != Entity.Null || sCoreAlive))
{
uint wTicks = (uint)math.max(1, sp.WindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
@@ -44,7 +44,9 @@ namespace ProjectM.Server
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().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);
+69 -2
View File
@@ -2536,15 +2536,25 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.WaveDirectorAuthoring
EnemyPrefabs:
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
- {fileID: 3885353946372160549, guid: f77a36036567c814496e6c59c42b2082, type: 3}
- {fileID: 3885353946372160549, guid: 31d233e9e507acf45a411f8ab0997bed, type: 3}
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
- {fileID: 3885353946372160549, guid: f29273bdfc85a8b49a4e16719e9a2568, type: 3}
- {fileID: 3885353946372160549, guid: b0921d4cb0eedd349a257759a157653a, type: 3}
RingRadius: 16
RingSlots: 10
BaseCount: 4
CountPerWave: 2
SpawnIntervalTicks: 24
LullTicks: 240
MaxAlive: 14
ChargerBase: 0
SpitterBase: 0
SwarmerSlotBase: 0
ChargerPerEpoch: 1
SpitterPerEpoch: 1
SwarmerSlotPerEpoch: 0
SwarmerPackSize: 3
SwarmerPackPerEpoch: 0
ClusterTightRadius: 2.5
--- !u!1 &1379903944
GameObject:
m_ObjectHideFlags: 0
@@ -3160,6 +3170,52 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 3.5, y: 8, z: 3.5}
m_Center: {x: 0, y: 4, z: 0}
--- !u!1 &1871304248
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1871304250}
- component: {fileID: 1871304249}
m_Layer: 0
m_Name: SpitterProjectileConfig
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1871304249
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1871304248}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4ce5223c5fd56694c81991e1bb1232de, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterProjectilePrefabAuthoring
ProjectilePrefab: {fileID: 3885353946372160549, guid: 8a13ef653e5266741b0896084f74038f, type: 3}
MaxLiveProjectiles: 24
--- !u!4 &1871304250
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1871304248}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1906259338
GameObject:
m_ObjectHideFlags: 0
@@ -3344,12 +3400,22 @@ MonoBehaviour:
EnemyPrefabs:
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
- {fileID: 3885353946372160549, guid: f29273bdfc85a8b49a4e16719e9a2568, type: 3}
- {fileID: 3885353946372160549, guid: b0921d4cb0eedd349a257759a157653a, type: 3}
MaxAlive: 12
RingRadius: 14
RingSlots: 10
SpawnIntervalTicks: 30
GruntsPerWave: 4
ChargersPerWave: 1
SpitterBase: 0
SwarmerSlotBase: 0
ChargerPerEpoch: 1
SpitterPerEpoch: 1
SwarmerSlotPerEpoch: 1
SwarmerPackSize: 4
SwarmerPackPerEpoch: 0
ClusterTightRadius: 2.5
RewardOre: 25
--- !u!4 &1957070224
Transform:
@@ -3748,3 +3814,4 @@ SceneRoots:
- {fileID: 722706770}
- {fileID: 2145598870}
- {fileID: 1957070224}
- {fileID: 1871304250}
@@ -0,0 +1,205 @@
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>
/// 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.
/// </summary>
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<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyAISystem>());
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<Prefab>(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<EnemyTag>(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<PlayerTag>(e);
}
static int CountSpits(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
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<AttackWindup>(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<EnemyProjectile>());
var spit = q.GetSingleton<EnemyProjectile>();
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<AttackWindup>(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<AttackWindup>(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<AttackWindup>(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<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<LungeState>(), ComponentType.ReadOnly<SpitterState>() },
});
using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<SpitterState>() },
});
using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<SpitterState>() },
None = new[] { ComponentType.ReadOnly<LungeState>() },
});
Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone<LungeState,SpitterState>)");
Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone<SpitterState>)");
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<SpitterState>(spitter).NextShotTick,
"soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2fc1d0d0241a80745a308575f71c701b
@@ -0,0 +1,114 @@
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>
/// 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.
/// </summary>
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<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<WaveSystem>());
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<EnemyTag>(p);
em.AddComponent<Prefab>(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<WaveEnemyPrefab>(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<EnemyTag>());
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<WaveState>(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<WaveState>(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<WaveState>(dir);
Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred");
Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96771355c799615499f6455055b66ca8
@@ -0,0 +1,33 @@
---
title: Combat Depth Slice — Enemy Variety (MC-2) + Impact (MC-3) — Build
date: 2026-06-24
tags: [session, combat, enemies, netcode, juice, slice, rukhanka]
permalink: gamevault/07-sessions/2026/2026-06-24-combat-depth-build
---
# Combat Depth (MC-2 + MC-3) — Build session
Built the combat-depth slice the operator chose after Slice 3 ("the combat needs a lot more work"). Spec + forks + the full build record are in [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]]. This log is the build narrative.
## What shipped
- **Two new enemy questions:** the **Spitter** (ranged reposition — holds a preferred range-band, fires a telegraphed dodgeable spit) and the **Swarmer** (surround — deterministic cluster spawn). On top of the existing Grunt (walk-up melee) + Charger (committed lunge) → four distinct readable questions.
- **4-type weighted mix** in BOTH directors (expedition `ZoneEnemyDirector` + base-siege `WaveSystem`, fork-4a) sharing one pure `ZoneEnemyMath` composition function, with the mandatory `MaxAlive` cap. Legacy size/charger curve kept + parity-tested.
- **MC-3 impact:** magnitude-scaled player-dealt-hit camera punch + the Spitter aim-LANE telegraph (camera-only hit-stop, never `Time.timeScale`).
- **Real rigged enemies:** Spitter = a re-skinned rigged Kaiju, Swarmer = a rigged Undead-Werewolf (fast/low-HP); grunt/charger unchanged. Spit projectile = `EnemySpit` ownerless interpolated ghost.
## How it went (verify ladder)
- **MC-2 server spine** committed first as `56cf60cce` (already green at the time).
- **MC-3 + tests + rigging + wiring** built via MCP. **388/388 EditMode** (added Spitter-brain, swarmer-cluster, projectile, mix, and 3 review-driven guard tests).
- **Play smoke (×2):** clean two-world boot (no `ComponentSystemSorter` cycle, no Burst ICE), Spitter fires → spit sweeps → player damaged (HP 105→25) → `DamageEvent` drained, region-correct, **replicated server→client**. Re-ran with the rigged Kaiju spitter after the fix: spawns + fires + damages, **zero console errors** (Rukhanka rig baked intact).
## Post-impl adversarial review (`wf_febdcfdb-665`)
3 lenses (netcode/relevancy · determinism/prediction · reuse/test-validity) → per-finding refutation. Caught **1 MED + 5 LOW**:
- **[MED, FIXED] Spitter fired from any range** — the wind-up commit lacked the locked in-band gate, so it would telegraph+fire while still advancing from far (defeating the hold-range question). Fixed: commit only when `sInBand || sCornered` (which also gave the previously-dead `CorneredRange` a real read site). The review's value re-confirmed — a true behavioral deviation 388 green tests + a clean Play had NOT caught.
- **[LOW, FIXED]** `EnemyProjectileDamageSystem` missing `!ServerTick.IsValid` guard; bake-time guard against a Charger+Spitter prefab; two test false-greens (Base==0 region assert → fire from Expedition; overstated partition claim → a direct partition-exclusion test).
- **[LOW, DOCUMENTED]** global (not per-region) spit soft-cap; co-op camera-punch attribution. Both per the locked design / generous bounds.
## Gotchas worth remembering
- **Director rosters are KIND-INDEXED (index == Kind 0..3), NOT round-robin.** The `WaveDirector` still held the OLD round-robin pool `[Werewolf, WerewolfUndead, Kaiju, ChargerMuscle]` — under MC-2 that silently maps the charger model into the Swarmer slot etc. Always rebuild the roster as `[Grunt,Charger,Spitter,Swarmer]` when adopting `KindForSlot`.
- **A `DynamicBuffer` handle is invalidated by any later structural change** — in a test, create the prefab entities BEFORE `AddBuffer`, populate with no `CreateEntity`/`AddComponent` in between (the cluster-test setup bug).
- **Reuse rigged variants, don't rebuild rigs.** A plain `AssetDatabase.CopyAsset` duplicate of a working Rukhanka prefab + adding a marker authoring bakes clean and dodges the `EnemyRigTools`/skeleton-rebuild risk entirely.
- The fun-gate co-op playtest (Slice 3's too) is still **pending** — the open question is whether the fight is fun, not test counts.
@@ -76,17 +76,24 @@ Adopt `WaveSlots`/`KindForSlot` + a 4-entry prefab buffer; **add `WaveDirector.M
Pure-math: `WaveSlots`≥1, per-epoch ramps, `KindForSlot` determinism + composition counts, swarmer bucketing, `PackSizeForSlot`≥1, `IsChargerSlot` wrapper keeps 4 legacy assertions; **parity** test (`WaveSlots` reproduces legacy curve); `BandVelocity` (retreat/advance/in-band/Y-flat); `ClusterOffset` determinism. System: `EnemyProjectileMove` integrate+LastStep+range-expiry; `EnemyProjectileDamage` append+at-most-once + **tunnelling regression** (LastStep>radius still hits) + **region-filter** (Expedition spit doesn't damage Base target); Spitter brain (advance/retreat/in-band-commit-then-spawn-with-Region, soft-cap no-burn); cornered fallback; dash-through-spit negation; cluster spawn (PackSize spawned, 1 slot consumed, pack-over-MaxAlive defers); discriminator routing (no double-visit); 4-entry buffer guard. **Play-validation:** no sort-cycle at world-creation; no Burst ICE; Spitter end-to-end (holds range, aim-line telegraph, dodgeable+dash-negatable spit); region correctness (no cross-region see/damage); swarmer reads as a swarm + respects MaxAlive; mix ramp visibly shifts; MC-3 magnitude-scaled punch + predicted tick-rate UNAFFECTED (no timeScale); perf (live spits ≤24, stable frame time under base siege + expedition swarm).
## Consequences
- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired).
- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired); **per-region** spit soft-cap (v1 is GLOBAL — 24 is generous); a co-op **local-attribution gate** for the player-dealt-hit punch (v1 keys on any enemy-Health edge, so a teammate's hit nudges your camera a touch); **bespoke Spitter/Swarmer art** (v1 reuses the rigged Kaiju + Undead-Werewolf models for distinct silhouettes).
- **Open (operator):** the combat fun-gate is a hands-on co-op playtest after build ("play with a friend and not want to stop"); the Slice 3 fun-gate still pending too.
- **Status:** reviewed + locked; build IN FLIGHT (see below). Full review (verdicts/blockers/forks) in run transcript `wf_eb115556-8cc`.
- **Status:** BUILT + post-impl-reviewed + Play-validated (2026-06-24). Pre-coding review `wf_eb115556-8cc`; post-impl adversarial review `wf_febdcfdb-665` (3 lenses → per-finding refutation; **1 MED + 3 LOW fixed in code, 2 LOW documented**). **388/388 EditMode** + a clean netcode Play smoke (boot, fire, swept-hit, region, server==client). Fun-gate co-op playtest still pending.
## Build progress (in flight — 2026-06-22)
## Build record (complete — 2026-06-24)
**Done + compiling clean (368/368 EditMode still green, backward-compatible at epoch 1):**
- Leaf components: `SpitterState`(+baked `WindupTicks`), `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands` (`Simulation/Combat/`).
- Math: `EnemyAIMath.{BandVelocity, ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept intact for parity).
- Systems: `EnemyProjectileMoveSystem` + `EnemyProjectileDamageSystem` (plain server group, LastStep swept, region filter); `EnemyAISystem` Spitter pass + partition guards (`WithNone<LungeState,SpitterState>` Grunt / `WithNone<SpitterState>` Charger) + `m_EnemyProjectiles` cache.
- Discriminator: `EnemyTelegraph.IsCharger→byte Kind`; `EnemyAuthoring` bakes Kind from sibling authoring.
- Authoring: `SpitterAuthoring`, `SwarmerAuthoring`, `EnemyProjectileAuthoring`; both director components (`ZoneEnemyDirector`, `WaveDirector`) + their authoring gained the mix/cluster fields + (Wave) mandatory `MaxAlive`. Base siege adopts `WaveSlots`/`KindForSlot`/cluster (fork 4a); defaults keep the size curve (≈+1 charger +1 spitter/wave) so the END-game stays bounded.
Built through the full `/dots-dev` ladder: **388/388 EditMode green** + a Play smoke (clean two-world boot, no sort-cycle/ICE; Spitter fires → spit sweeps → player damaged → drained, region-correct, **server==client**) + the post-impl adversarial review (`wf_febdcfdb-665`). Two commits: the **MC-2 server spine** (`56cf60cce`) and the **MC-3 + tests + wiring + review-fixes** (one commit, on operator go).
**Remaining:** MC-3 client juice (FeelConfig fields + `CombatFeedbackSystem` player-hit camera punch + Spitter `Kind==2` aim-line); the additive EditMode tests (per the test plan above); prefab + subscene wiring (Spitter/Swarmer/EnemyProjectile ghosts via the new-ghost recipe, 4-entry director rosters, `SpitterProjectilePrefab` singleton, MixBands/MaxAlive on both directors); then the verify ladder + Play smoke + post-impl review + doc bookend + commit. **Resume from here if compacted.**
**Shipped (MC-2 spine):** `SpitterState`, `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands`; `EnemyAIMath.{BandVelocity,ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept + parity-tested); `EnemyProjectileMove/DamageSystem` (plain server group, LastStep swept, region filter, at-most-once destroy); `EnemyAISystem` Spitter pass + partition guards; `EnemyTelegraph.IsCharger→byte Kind` + `EnemyAuthoring` Kind baking.
**Shipped (MC-3 juice):** 7 `FeelConfig` fields (+ `ResetDefaults`); magnitude-scaled **player-dealt-hit `PunchFov`** on the enemy-`Health`-decrease edge; the **Spitter `Kind==2` aim-LANE** telegraph (`BuildLaneMesh`; reads baked `SpitterState` client-side, falls back to a fixed length). True freeze-frame + `[MaterialProperty]` enemy hit-flash DEFERRED (gated off / own ShaderGraph slice).
**Shipped (content):** `SpitterProjectilePrefabAuthoring` (the singleton); both directors carry a 4-entry **kind-indexed** roster `[Grunt,Charger,Spitter,Swarmer]` + mix/MaxAlive config (fork-4a base siege included) + the `SpitterProjectileConfig` singleton. **Real rigged models:** Spitter = `EnemySpitter` (rigged Kaiju, dialed to a ranged poker — HP 28/spd 2.8/cd 66), Swarmer = `EnemySwarmerUndead` (rigged Undead-Werewolf — HP 8/spd 6.5/cd 24, scale 0.6); grunt/charger keep Werewolf/ChargerMuscle. Spit = `EnemySpit` (ownerless interpolated ghost, no `Health`, no collider). All bake clean (zero console errors, Rukhanka rig intact). *(The simple-mesh `Enemy*` prototypes were left untouched; `EnemySwarmer.prefab` reverted.)*
**Post-impl review fixes (`wf_febdcfdb-665`, all re-validated 388/388 + a re-Play):**
- **[MED] in-band fire gate** — the Spitter committed its telegraph from ANY range (would fire while advancing from far, defeating the hold-range question). Fixed: commit only when `sInBand || sCornered` (which gives the once-dead `CorneredRange` a real read site — a Spitter advancing-from-far OR retreating-from-too-close now holds fire and repositions). Guarded by new `Spitter_OutOfBand_DoesNotCommitWindup` + `Spitter_Cornered_CommitsWindupPointBlank`.
- **[LOW] `EnemyProjectileDamageSystem`** now early-returns on `!ServerTick.IsValid` (sibling-system parity).
- **[LOW] bake-time Charger+Spitter guard** — `EnemyAuthoring.Bake` `Debug.LogError`s a prefab composing BOTH (it would match zero AI passes → never move).
- **[LOW] test quality** — the Spitter brain test now fires from **Expedition** (a dropped `Region` copy = 0 = Base would fail it, killing the `Base==0` false-green); the overstated "proves the partition" claim is replaced by a **direct** partition-exclusion test (`Spitter_IsExcludedFromGruntAndChargerPasses`).
**Accepted (documented, not defects):** the `MaxLiveProjectiles` soft-cap is GLOBAL across regions (24 generous; per-region deferred); the player-dealt-hit punch keys on the enemy-`Health` edge per the locked design (a teammate's hit nudges your camera a touch in co-op — a future local-attribution gate). See the Deferred list above.