Vault Re-Alignment

This commit is contained in:
2026-06-09 23:26:20 -07:00
parent a7405c3f38
commit da522efe7a
63 changed files with 119048 additions and 15 deletions
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+133
View File
@@ -0,0 +1,133 @@
# the name by which the project can be referenced within Serena
project_name: "Project-M"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- csharp
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
@@ -0,0 +1,77 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-592490988042947487
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_Enemy_Charger_Animated
m_Shader: {fileID: -6465566751694194690, guid: 32ea7846f763cb340950fafed798ecf6, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords:
- _USE_VERTEX_COLOR
m_InvalidKeywords: []
m_LightmapFlags: 2
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseColorMap:
m_Texture: {fileID: 2800000, guid: db556d465e9f4ab4caffaf339cf5b656, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MaskMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _NormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _DeformedMeshIndex: 0
- _Metallic: 0
- _QueueControl: 0
- _QueueOffset: 0
- _Smoothness: 0
- _USE_VERTEX_COLOR: 1
m_Colors:
- _BaseColor: {r: 1, g: 0.42, b: 0.36, a: 1}
- _DeformationParamsForMotionVectors: {r: 0, g: 0, b: 0, a: 0}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d043f274e34de8a43b8c0201f2edaafd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+164
View File
@@ -0,0 +1,164 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !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: 3909651526955663392}
- component: {fileID: 3320445911748035220}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 2544095781123180609}
- component: {fileID: 5988865165041574521}
m_Layer: 0
m_Name: EnemyCharger
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: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3909651526955663392
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &3320445911748035220
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
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: fc4f991205d0aa347b65d89045f80c70, 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: 1
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!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 &2544095781123180609
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: 6b014797e9092694b9568c5b66d34a55, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 45
HitRadius: 0.8
MoveSpeed: 2.6
AttackRange: 1.7
AttackDamage: 14
AttackCooldownTicks: 48
--- !u!114 &5988865165041574521
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: 9565191e0ea7fc94db934ae91a43a4cf, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ChargerAuthoring
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6da6b938e0fd50f48a885f6f50ac9d61
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0d42b4a8ef76489458ee1ecf51b4dbca
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-1 — marks a Husk prefab as a CHARGER variant. Compose this WITH <see cref="EnemyAuthoring"/> on the
/// prefab root (both bakers share the primary entity): EnemyAuthoring bakes the common Husk components and
/// Charger-tuned stats; this bakes the server-only <see cref="LungeState"/> (zeroed = not lunging).
/// Component-PRESENCE is the discriminator <c>EnemyAISystem</c> branches on — no enum/brain byte (the Burst
/// cross-assembly-enum hazard) — routing the Charger to the commit→lunge→whiff-stagger pass while the Grunt
/// pass excludes it via <c>.WithNone&lt;LungeState&gt;()</c>. NOT a <c>[GhostField]</c>: the lunged position
/// replicates via stock LocalTransform like every Husk.
/// </summary>
public class ChargerAuthoring : MonoBehaviour
{
private class ChargerBaker : Baker<ChargerAuthoring>
{
public override void Bake(ChargerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<LungeState>(entity);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9565191e0ea7fc94db934ae91a43a4cf
@@ -84,6 +84,9 @@ namespace ProjectM.Authoring
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddComponent<AbilityCooldown>(entity);
AddBuffer<DamageEvent>(entity);
// MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready.
AddComponent<DashState>(entity);
AddComponent(entity, new DashCooldown { NextTick = 0 });
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
// plus the server-only respawn timer.
@@ -62,6 +62,20 @@ namespace ProjectM.Client
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
GUILayout.EndHorizontal();
GUILayout.Space(6);
GUILayout.Label("- Telemetry (MC-0) -");
if (DevTelemetryReadout.HasData)
{
var t = DevTelemetryReadout.Latest;
GUILayout.Label($"tick {t.LastSampleTick} husks {t.LiveEnemyCount}");
GUILayout.Label($"dash neg {t.DashIFrameNegatedHits} / wasted {t.DashesWasted}");
GUILayout.Label($"whiff open {t.ChargerWhiffWindowsOpened} / punish {t.ChargerWhiffPunishesLanded}");
}
else
{
GUILayout.Label("(waiting for server telemetry...)");
}
GUILayout.EndArea();
}
@@ -0,0 +1,78 @@
#if UNITY_EDITOR
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// MC-0 — EDITOR-ONLY client receiver for the periodic <see cref="ProjectM.Simulation.DebugTelemetryReport"/>.
/// Drains the snapshot into <see cref="DevTelemetryReadout"/> (a plain static) so the IMGUI <c>DebugOverlay</c>
/// reads NO ECS state directly (the job-safety rule for presentation). Plain client
/// <see cref="SimulationSystemGroup"/>; non-Burst (touches a managed static).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTelemetryReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<ProjectM.Simulation.DebugTelemetryReport, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (report, reqEntity) in
SystemAPI.Query<RefRO<ProjectM.Simulation.DebugTelemetryReport>>()
.WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
{
var r = report.ValueRO;
DevTelemetryReadout.Latest = new DevTelemetryReadout.Snapshot
{
DashIFrameNegatedHits = r.DashIFrameNegatedHits,
DashesWasted = r.DashesWasted,
ChargerWhiffWindowsOpened = r.ChargerWhiffWindowsOpened,
ChargerWhiffPunishesLanded = r.ChargerWhiffPunishesLanded,
LiveEnemyCount = r.LiveEnemyCount,
LastSampleTick = r.LastSampleTick,
};
DevTelemetryReadout.HasData = true;
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
/// <summary>
/// MC-0 — static bridge from the ECS telemetry receiver to the IMGUI <c>DebugOverlay</c> (so the overlay reads
/// a plain struct, never ECS state). Reset on play-enter so a fast-enter-playmode reload can't show stale data.
/// </summary>
public static class DevTelemetryReadout
{
public struct Snapshot
{
public uint DashIFrameNegatedHits;
public uint DashesWasted;
public uint ChargerWhiffWindowsOpened;
public uint ChargerWhiffPunishesLanded;
public uint LiveEnemyCount;
public uint LastSampleTick;
}
public static Snapshot Latest;
public static bool HasData;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Reset()
{
Latest = default;
HasData = false;
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 685c34765eac3434aadf08c13fce1aa5
@@ -77,6 +77,7 @@ namespace ProjectM.Client
var gamepad = UnityEngine.InputSystem.Gamepad.current;
var mouse = UnityEngine.InputSystem.Mouse.current;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)) && !BuildPaletteState.Active;
float2 rightStick = float2.zero;
bool gamepadActive = false;
@@ -102,7 +103,7 @@ namespace ProjectM.Client
kbmActive =
keyboard.wKey.isPressed || keyboard.aKey.isPressed || keyboard.sKey.isPressed || keyboard.dKey.isPressed ||
keyboard.upArrowKey.isPressed || keyboard.downArrowKey.isPressed ||
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed;
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed || keyboard.leftShiftKey.isPressed;
}
if (gamepadActive && kbmActive)
@@ -160,6 +161,9 @@ namespace ProjectM.Client
input.ValueRW.Fire = default;
if (firePressed)
input.ValueRW.Fire.Set();
input.ValueRW.Dash = default;
if (dashPressed)
input.ValueRW.Dash.Set();
}
}
@@ -52,14 +52,18 @@ namespace ProjectM.Client
ParticleSystem _hitFx;
ParticleSystem _deathFx;
ParticleSystem _muzzleFx;
ParticleSystem _dashFx;
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
AudioClip _telegraphClip;
AudioClip _dashClip;
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
bool _fireTickInit;
uint _lastLocalDashTick;
bool _dashTickInit;
const int NumberPoolSize = 32;
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
@@ -72,6 +76,7 @@ namespace ProjectM.Client
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
}
protected override void OnStartRunning()
@@ -83,6 +88,7 @@ namespace ProjectM.Client
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber());
@@ -104,6 +110,8 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
@@ -115,6 +123,17 @@ namespace ProjectM.Client
localPos = xf.ValueRO.Position;
}
// Client-derived dash window of the LOCAL player (DashSystem runs in the client prediction loop
// too): drives the i-frame shimmer + the hit-feedback suppression below. Observe-only.
bool localIFrameActive = false;
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashState>(_localPlayer)
&& SystemAPI.TryGetSingleton<NetworkTime>(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
{
var localDash = EntityManager.GetComponentData<DashState>(_localPlayer);
localIFrameActive = localDash.IFrameUntilTick != 0u
&& new NetworkTick(localDash.IFrameUntilTick).IsNewerThan(dashNetTime.ServerTick);
}
// Edge-detect Health on every damageable ghost (players + Husks).
_seen.Clear();
foreach (var (health, xf, entity) in
@@ -136,7 +155,9 @@ namespace ProjectM.Client
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
}
if (cur < prev.Hp - 0.001f)
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
if (cur < prev.Hp - 0.001f && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
@@ -201,6 +222,26 @@ namespace ProjectM.Client
_fireTickInit = true;
}
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
// (replicated [GhostField], predicted both sides; raw uint edge like the muzzle flash — cosmetic
// only). Whoosh + afterimage burst + camera punch on start, shimmer trail while i-frames last.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashCooldown>(_localPlayer))
{
uint nextDash = EntityManager.GetComponentData<DashCooldown>(_localPlayer).NextTick;
if (_dashTickInit && nextDash != 0 && nextDash != _lastLocalDashTick)
{
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.6f, FeelConfig.DashBurstCount);
PlayClip(_dashClip, (Vector3)localPos, FeelConfig.DashSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.DashShake);
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick, FeelConfig.HitStopDurationMs);
}
_lastLocalDashTick = nextDash;
_dashTickInit = true;
if (localIFrameActive) // i-frame shimmer trail while the local window is active
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame);
}
UpdateProjectileTrails(cfg);
PruneVfx();
AnimateNumbers(dt, cam);
@@ -74,6 +74,21 @@ namespace ProjectM.Client
/// <summary>Tether line width (world units).</summary>
public static float LockOnLineWidth;
// ---- Feature 5 (MC-1): dash juice ----
/// <summary>Camera shake on the local player's dash start.</summary>
public static float DashShake;
/// <summary>Transient FOV punch (degrees) on dash start — the "lurch" read (camera punch, never Time.timeScale).</summary>
public static float DashFovKick;
/// <summary>Afterimage/whoosh particle burst count at dash start.</summary>
public static int DashBurstCount;
/// <summary>Dash whoosh SFX volume.</summary>
public static float DashSfxVolume;
/// <summary>Particles emitted per frame while the local i-frame window is active (the shimmer trail).</summary>
public static int DashShimmerPerFrame;
/// <summary>Suppress local hit-feedback during the local i-frame window (masks the prediction-reconciliation
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
public static bool DashHitSuppress;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
@@ -108,6 +123,14 @@ namespace ProjectM.Client
LockOnArcDegrees = 60f;
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
LockOnLineWidth = 0.05f;
// Feature 5 dash (MC-1)
DashShake = 0.18f;
DashFovKick = 1.2f;
DashBurstCount = 14;
DashSfxVolume = 0.55f;
DashShimmerPerFrame = 2;
DashHitSuppress = true;
}
}
}
@@ -37,6 +37,11 @@ namespace ProjectM.EditorTools
const string SyntyWerewolf = "Assets/Synty/PolygonWerewolf/Prefabs/Characters/SM_Chr_Werewolf_01.prefab";
const string SyntyKaiju = "Assets/Synty/PolygonKaiju/Prefabs/Characters/SM_Chr_Kaiju_01.prefab";
// MC-1 Charger (SciFi-City Muscle — verified Generic rig, distinct charging silhouette; see Synty_Asset_Inventory).
const string ChargerAtlas = "Assets/Synty/PolygonSciFiCity/Textures/Alts/PolygonScifi_01_A.png";
const string MatCharger = "Assets/_Project/Materials/M_Enemy_Charger_Animated.mat";
const string SyntyMuscle = "Assets/Synty/PolygonSciFiCity/Prefabs/Characters/SM_Chr_Muscle_Male_01.prefab";
struct Variant
{
public string Name, Template, Synty, Output, Material;
@@ -49,8 +54,12 @@ namespace ProjectM.EditorTools
new Variant { Name = "Grunt (Werewolf)", Template = "Assets/_Project/Prefabs/Enemy.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolf.prefab", Material = MatWerewolf, RootY = -1.25f, Scale = 0f },
new Variant { Name = "Swarmer (Werewolf Undead)", Template = "Assets/_Project/Prefabs/EnemySwarmer.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolfUndead.prefab", Material = MatWerewolfUndead, RootY = -1.67f, Scale = 0f },
new Variant { Name = "Brute (Kaiju)", Template = "Assets/_Project/Prefabs/EnemyBrute.prefab", Synty = SyntyKaiju, Output = "Assets/_Project/Prefabs/EnemyKaiju.prefab", Material = MatKaiju, RootY = -0.52f, Scale = 0f },
ChargerVariant(),
};
/// <summary>MC-1 Charger: SciFi-City Muscle silhouette via the standard DR-023 pipeline (the inventory's prescribed next-faction path). Template scale 1.0 -> RootY -1.0 (humanoid -1/scale rule); fine-tune feet in Play.</summary>
static Variant ChargerVariant() => new Variant { Name = "Charger (SciFi Muscle)", Template = "Assets/_Project/Prefabs/EnemyCharger.prefab", Synty = SyntyMuscle, Output = "Assets/_Project/Prefabs/EnemyChargerMuscle.prefab", Material = MatCharger, RootY = -1.00f, Scale = 0f };
[MenuItem("ProjectM/Animation/Enemy Rigs - Build All")]
public static void BuildAll()
{
@@ -60,6 +69,18 @@ namespace ProjectM.EditorTools
Debug.Log("[EnemyRigTools] Build All complete. Re-point WaveDirector.EnemyPrefabs[] at the new prefabs and re-bake the gameplay subscene.");
}
/// <summary>MC-1: build ONLY the Charger material + prefab (leaves the committed Werewolf/Kaiju outputs untouched).</summary>
[MenuItem("ProjectM/Animation/Enemy Rigs - Build Charger (MC-1)")]
public static void BuildCharger()
{
MakeMat(MatCharger, ChargerAtlas, new Color(1f, 0.42f, 0.36f, 1f)); // red-shifted SciFi atlas = danger read
AssetDatabase.SaveAssets();
BuildOne(ChargerVariant());
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[EnemyRigTools] Charger built -> EnemyChargerMuscle.prefab; add it to WaveDirector.EnemyPrefabs[] in the gameplay subscene.");
}
// ---- 1. Materials ------------------------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 1 Build Materials")]
@@ -96,6 +96,7 @@ namespace ProjectM.Server
{
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);
@@ -71,7 +71,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
.WithAll<EnemyTag>())
.WithAll<EnemyTag>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
@@ -145,6 +145,7 @@ namespace ProjectM.Server
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
SourceTick = TickUtil.NonZero(now),
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
@@ -170,6 +171,138 @@ namespace ProjectM.Server
}
}
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
const float ChargerLungeSpeed = 16f; // units/s while lunging
const uint ChargerLungeDurationTicks = 18; // ~0.30 s of committed travel
const uint ChargerWindupTicks = 30; // ~0.50 s readable telegraph (>= interp delay + reaction)
const uint ChargerWhiffStaggerTicks = 36; // ~0.60 s punish window on a whiff
uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
lunge.ValueRW.UntilTick = 0;
continue;
}
knockback.ValueRW.UntilTick = 0;
}
// Nearest living player (reuse the snapshot taken above).
int cbest = -1; float cbestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float2 dd = playerPositions[i].xz - pos.xz;
float sq = math.lengthsq(dd);
if (sq < cbestSq) { cbestSq = sq; cbest = i; }
}
float3 cTargetPos = playerPositions[cbest];
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
var lg = lunge.ValueRO;
if (lg.UntilTick != 0)
{
var lgTick = new NetworkTick(lg.UntilTick);
if (lgTick.IsValid && lgTick.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lg.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up());
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
{
ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1,
SourceTick = TickUtil.NonZero(now),
});
uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks);
lunge.ValueRW.UntilTick = 0; // landed -> end the lunge
}
else
{
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window)
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
}
continue; // committed this tick
}
// Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick.
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0;
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
// 3. Seek + face (shared shape with the Grunt path).
float cStop = stats.ValueRO.AttackRange * 0.9f;
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = cNewPos;
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
if (math.lengthsq(cToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
// 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the
// whole point is the commit lands even if the player dodged out of range (the punishable tell).
uint cWindRaw = windup.ValueRO.WindUpUntilTick;
if (cWindRaw != 0)
{
var cWindTick = new NetworkTick(cWindRaw);
if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick)))
{
float3 toT = cTargetPos - pos; toT.y = 0f;
float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f);
lunge.ValueRW.Dir = ldir;
lunge.ValueRW.Speed = ChargerLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
else
{
bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange);
if (cInRange)
{
bool cReady = cooldown.ValueRO.NextAttackTick == 0
|| !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick);
if (cReady)
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks);
}
}
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -25,6 +25,12 @@ namespace ProjectM.Server
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(ProjectileDamageSystem))]
// Pin the drain AFTER DashSystem: a same-tick player-sourced projectile (ProjectileDamageSystem stamps
// SourceTick = now and this system drains the SAME tick) must see a dash window STARTED this tick —
// without the edge the negation at src == StartTick is an unconstrained sorter tiebreak. The Dash chain
// (StatRecompute→PlayerControl→Dash) and the projectile chain (PlayerAim→AbilityFire→ProjectileMove→
// ProjectileDamage→here) are otherwise disjoint, so this edge cannot form a cycle (Play-validated).
[UpdateAfter(typeof(DashSystem))]
[BurstCompile]
public partial struct HealthApplyDamageSystem : ISystem
{
@@ -33,6 +39,8 @@ namespace ProjectM.Server
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime);
uint negatedThisTick = 0;
uint punishesThisTick = 0;
foreach (var (health, dmg, entity) in
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
@@ -63,10 +71,55 @@ namespace ProjectM.Server
}
}
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
uint negatedForThisEntity = 0u;
float total = 0f;
for (int i = 0; i < dmg.Length; i++)
{
uint src = dmg[i].SourceTick;
if (hasDash && src != 0u && ds.IFrameUntilTick != 0u)
{
var srcTick = new NetworkTick(src);
var startTick = new NetworkTick(ds.StartTick);
var untilTick = new NetworkTick(ds.IFrameUntilTick);
// Dash i-frames cover the HALF-OPEN window [StartTick, IFrameUntilTick): negate iff src >= start AND src < until.
bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick);
bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick);
if (atOrAfterStart && beforeUntil)
{
negatedThisTick++;
negatedForThisEntity++;
continue; // dash i-frame negates this hit (per-element, not a whole-buffer clear)
}
}
total += dmg[i].Amount;
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
if (isCharger && dmg[i].SourceNetworkId >= 0)
{
var lunge = SystemAPI.GetComponent<LungeState>(entity);
if (lunge.StaggerUntilTick != 0u)
{
var stag = new NetworkTick(lunge.StaggerUntilTick);
if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick))
{
punishesThisTick++;
lunge.StaggerUntilTick = 0u;
SystemAPI.SetComponent(entity, lunge);
}
}
}
}
dmg.Clear();
if (negatedForThisEntity != 0u)
{
ds.NegatedCount += negatedForThisEntity; // server-side spam signal; DashSystem reads it at window-close
SystemAPI.SetComponent(entity, ds);
}
float newHp = health.ValueRO.Current - total;
@@ -83,6 +136,12 @@ namespace ProjectM.Server
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity)))
ecb.DestroyEntity(entity);
}
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
{
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
telem.ValueRW.DashIFrameNegatedHits += negatedThisTick;
telem.ValueRW.ChargerWhiffPunishesLanded += punishesThisTick;
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -130,6 +130,7 @@ namespace ProjectM.Server
{
Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId,
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
@@ -0,0 +1,69 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// MC-0 — EDITOR-ONLY server telemetry sampler/sender. Ensures the <see cref="DevTelemetry"/> singleton,
/// samples live-enemy-count + the server tick each tick, and every <see cref="ReportPeriodTicks"/> ships a
/// <see cref="DebugTelemetryReport"/> snapshot to every connection (so the dev overlay shows live fun-gate
/// counters over a real connection too). Combat systems increment the real counters at the stamp sites (MC-1+).
/// Plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple,
/// editor-only). Stripped from builds; the wire TYPE <see cref="DebugTelemetryReport"/> is unconditional.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTelemetrySystem : ISystem
{
const uint ReportPeriodTicks = 15;
EntityQuery m_Husks;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
state.EntityManager.CreateEntity(typeof(DevTelemetry));
}
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
telem.ValueRW.LiveEnemyCount = (uint)m_Husks.CalculateEntityCount();
telem.ValueRW.LastSampleTick = now;
if (now == 0 || (now % ReportPeriodTicks) != 0)
return;
var t = telem.ValueRO;
var report = new DebugTelemetryReport
{
DashIFrameNegatedHits = t.DashIFrameNegatedHits,
DashesWasted = t.DashesWasted,
ChargerWhiffWindowsOpened = t.ChargerWhiffWindowsOpened,
ChargerWhiffPunishesLanded = t.ChargerWhiffPunishesLanded,
LiveEnemyCount = t.LiveEnemyCount,
LastSampleTick = t.LastSampleTick,
};
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (netId, connEnt) in SystemAPI.Query<RefRO<NetworkId>>().WithEntityAccess())
{
var req = ecb.CreateEntity();
ecb.AddComponent(req, report);
ecb.AddComponent(req, new SendRpcCommandRequest { TargetConnection = connEnt });
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef1e1f5e7e01b77489dcb181652176a0
@@ -16,5 +16,11 @@ namespace ProjectM.Simulation
/// <summary>NetworkId of the firing player that caused this hit (attribution / self-hit filtering upstream).</summary>
public int SourceNetworkId;
/// <summary>Raw ServerTick at which this hit logically LANDS (the appending tick), stamped via
/// <c>TickUtil.NonZero</c> at every append site (0 = unstamped). The dash i-frame negation compares it
/// against the dashing player's <c>DashState</c> window, so a strike appended a tick before it is
/// drained is judged against the tick it was AUTHORED, not the tick it was applied.</summary>
public uint SourceTick;
}
}
@@ -0,0 +1,33 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger
/// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted):
/// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch
/// (which excludes these via <c>.WithNone&lt;LungeState&gt;()</c>). On a wind-up commit the Charger LOCKS
/// <see cref="Dir"/> toward the target and travels at <see cref="Speed"/> until <see cref="UntilTick"/> — dealing
/// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot).
/// NOT a <c>[GhostField]</c> (the lunged position replicates via the stock LocalTransform variant, like
/// KnockbackState). All ticks via <c>TickUtil.NonZero</c>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
/// </summary>
public struct LungeState : IComponentData
{
/// <summary>Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y).</summary>
public float2 Dir;
/// <summary>Lunge speed (world units/s); only meaningful while <see cref="UntilTick"/> is active.</summary>
public float Speed;
/// <summary>Raw tick the lunge ends (NonZero). <c>0</c> = not lunging. Active while .IsNewerThan(serverTick).</summary>
public uint UntilTick;
/// <summary>Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not
/// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit
/// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
public uint StaggerUntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc65446b98bef1040bc5b9beaac094ba
@@ -0,0 +1,49 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-0 — server-only dev-telemetry accumulator (a singleton). Counters are incremented at the
/// combat stamp sites (wired in MC-1+) so the fun-gate is MEASURED, not argued. NOT a
/// <c>[GhostField]</c> (no ghost-hash change); shipped to dev clients via a periodic
/// <see cref="DebugTelemetryReport"/> RPC. The component type is unconditional (stable across
/// release/dev peers); only the dev send/sample/receive SYSTEMS are <c>#if UNITY_EDITOR</c>.
/// </summary>
public struct DevTelemetry : IComponentData
{
/// <summary>Hits a dash i-frame window negated (incremented in HealthApplyDamageSystem, MC-1).</summary>
public uint DashIFrameNegatedHits;
/// <summary>Dashes whose i-frame window negated nothing (spam signal, MC-1).</summary>
public uint DashesWasted;
/// <summary>Charger lunges that whiffed and opened a punish window (EnemyAISystem, MC-1).</summary>
public uint ChargerWhiffWindowsOpened;
/// <summary>Of those, the ones the player actually punished (MC-1).</summary>
public uint ChargerWhiffPunishesLanded;
/// <summary>Living Husks, sampled each report — proof-of-life (changes during play, no MC-1 dep).</summary>
public uint LiveEnemyCount;
/// <summary>Server tick at the last sample — proof-of-life that the pipe is live.</summary>
public uint LastSampleTick;
}
/// <summary>
/// MC-0 — server → dev-client telemetry snapshot (sent periodically by the editor-only sampler).
/// <b>Unconditional wire type</b> (like <see cref="DebugCommandRequest"/>) so the reflection-built
/// RpcCollection hash matches across release/dev peers; only the send/receive SYSTEMS are
/// <c>#if UNITY_EDITOR</c>. The dev overlay reads the latest snapshot to show live fun-gate counters.
/// </summary>
public struct DebugTelemetryReport : IRpcCommand
{
public uint DashIFrameNegatedHits;
public uint DashesWasted;
public uint ChargerWhiffWindowsOpened;
public uint ChargerWhiffPunishesLanded;
public uint LiveEnemyCount;
public uint LastSampleTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6c5dfccd35c016940914a8357204f4e8
@@ -0,0 +1,19 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — predicted per-player dash cooldown gate (an <c>AbilityCooldown</c> twin). <c>[GhostField]</c> so the
/// owning client does not mispredict the cooldown across rollback / reconnect: re-predicted ticks see the same
/// authoritative gate the server applied and converge without a double-dash. <c>0</c> = ready; set to
/// <c>serverTick + dashCooldownTicks</c> via <c>TickUtil.NonZero</c> on dash-start; compare by wrapping into a
/// <see cref="NetworkTick"/> and using <see cref="NetworkTick.IsNewerThan"/> (raw uint subtraction is unsafe
/// across tick wraparound). Baked <c>{NextTick = 0}</c>.
/// </summary>
public struct DashCooldown : IComponentData
{
/// <summary>Raw tick of the earliest tick the player may dash again. <c>0</c> = ready.</summary>
[GhostField] public uint NextTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6f32a690284a3a47bf02829323ed8f5
@@ -0,0 +1,33 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — predicted, NON-replicated dash window on the owner-predicted player. A SHAPE-clone of
/// <c>KnockbackState</c>, but UNLIKE KnockbackState it lives on a PREDICTED player and is re-simulated from
/// the replicated <see cref="PlayerInput.Dash"/> InputEvent every predicted tick — so it is authoritative on
/// the server at the tick <c>HealthApplyDamageSystem</c> drains damage, even for a melee strike appended a
/// tick earlier in the plain group. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="Unity.NetCode.NetworkTick"/> only (never raw uint). NOT a <c>[GhostField]</c> (no player-ghost
/// re-bake). Baked all-zero (idle).
/// </summary>
public struct DashState : IComponentData
{
/// <summary>Planar XZ dash heading, captured at dash-start.</summary>
public float2 Dir;
/// <summary>Raw ServerTick at dash-start (NonZero-coerced). Lower (inclusive) bound of the i-frame window.</summary>
public uint StartTick;
/// <summary>StartTick + i-frame window (NonZero). I-frames cover the HALF-OPEN range [StartTick, IFrameUntilTick).</summary>
public uint IFrameUntilTick;
/// <summary>IFrameUntilTick + recovery tail (NonZero). Movement-lock tail (no i-frames) so a panic-dash is punishable.</summary>
public uint RecoverUntilTick;
/// <summary>Hits negated by THIS dash's i-frame window. SERVER-written (HealthApplyDamageSystem);
/// 0 at window-close = a wasted dash (DevTelemetry.DashesWasted spam signal). The client copy stays 0.</summary>
public uint NegatedCount;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28b7317dff9952841a0fb4b66df54f90
@@ -0,0 +1,113 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — the predicted dodge dash. On a fresh <see cref="PlayerInput.Dash"/> press (cooldown ready and not
/// already mid-dash) it captures the dash heading from <see cref="PlayerFacing"/> and opens a HALF-OPEN i-frame
/// window [StartTick, IFrameUntilTick) plus a recovery tail. While the i-frame window is active it OVERRIDES
/// <see cref="CharacterControl.MoveVelocity"/> with the dash velocity and raises
/// <see cref="CharacterComponent.GroundedMovementSharpness"/> to ~200 so the move reads as a BLINK (the CC
/// processor lerps RelativeVelocity toward MoveVelocity at that sharpness — no CharacterProcessor edit needed).
/// During the recovery tail movement is locked to zero (no i-frames) so a panic-dash is punishable.
/// <see cref="ProjectM.Server"/>'s HealthApplyDamageSystem reads the window to negate hits authored inside it.
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it overrides
/// the input-derived MoveVelocity that system wrote this tick) and is gated
/// <c>.WithAll&lt;Simulate&gt;().WithDisabled&lt;Dead&gt;()</c>. The START is an idempotent pure function of
/// replicated input + tick (no IsFirstTimeFullyPredictingTick guard); the OVERRIDE re-applies on EVERY predicted
/// pass so rollback re-simulation converges. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="NetworkTick"/> only. DashSystem owns GroundedMovementSharpness on the player (base = the CC default
/// 15); PlayerDeathStateSystem restores it + clears the window on death.
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[BurstCompile]
public partial struct DashSystem : ISystem
{
// Baked-first feel knobs (MC-1; promote to a live TuningConfig later). Sim runs at 60 ticks/sec.
const float DashDistance = 4.0f; // world units covered during the i-frame window
const uint IFrameWindowTicks = 12; // ~0.20 s of i-frames
const uint RecoverTailTicks = 9; // ~0.15 s movement-locked tail (punishes spam)
const uint DashCooldownTicks = 45; // ~0.75 s
const float DashSharpness = 200f; // GroundedMovementSharpness during the dash -> blink
const float DefaultSharpness = 15f; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f;
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate);
foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
.WithAll<Simulate>().WithDisabled<Dead>())
{
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
bool ready = cd.ValueRO.NextTick == 0u
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
{
float2 dir = facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
ds.ValueRW.Dir = dir;
ds.ValueRW.StartTick = TickUtil.NonZero(now);
ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + IFrameWindowTicks);
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + IFrameWindowTicks + RecoverTailTicks);
cd.ValueRW.NextTick = TickUtil.NonZero(now + DashCooldownTicks);
}
// --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) ---
// The lower bound matters: DashState is non-replicated, so prediction rollback does NOT restore
// it — a re-simulated PRE-dash tick (serverTick < StartTick) still sees the post-press window and,
// gated on the upper bound alone, would stomp dash velocity onto ticks that never had it
// (dash-start overshoot under real latency). Membership = the half-open [StartTick, …) test.
bool inDashWindow = ds.ValueRO.StartTick != 0u
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick);
bool iFrameActive = inDashWindow && ds.ValueRO.IFrameUntilTick != 0u
&& new NetworkTick(ds.ValueRO.IFrameUntilTick).IsNewerThan(serverTick);
bool recoverActive = inDashWindow && ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (iFrameActive)
{
float2 d = ds.ValueRO.Dir;
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
character.ValueRW.GroundedMovementSharpness = DashSharpness;
}
else if (recoverActive)
{
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
}
else
{
if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness)
character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // restore after the dash
// Window-close edge: score a wasted dash (negated nothing) ONCE, then clear the window.
// SERVER-only — the DevTelemetry singleton exists only in the (editor) server world; the
// client keeps its copy un-zeroed so rollback re-simulation of the tail stays intact. All
// in-window strikes drain >= 9 ticks before this edge, so clearing can't eat a negation.
if (ds.ValueRO.RecoverUntilTick != 0u && SystemAPI.TryGetSingletonRW<DevTelemetry>(out var telem))
{
if (ds.ValueRO.NegatedCount == 0u)
telem.ValueRW.DashesWasted++;
ds.ValueRW = default;
}
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58536af899e5e9442b81c594c17bc034
@@ -27,15 +27,28 @@ namespace ProjectM.Simulation
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (health, control, deadEnabled) in
foreach (var (health, control, deadEnabled, entity) in
SystemAPI.Query<RefRO<Health>, RefRW<CharacterControl>, EnabledRefRW<Dead>>()
.WithAll<PlayerTag, Simulate>()
.WithPresent<Dead>())
.WithPresent<Dead>()
.WithEntityAccess())
{
bool isDead = health.ValueRO.Current <= 0f;
deadEnabled.ValueRW = isDead;
if (isDead)
{
control.ValueRW.MoveVelocity = float3.zero;
// MC-1: clear any in-flight dash window + restore base sharpness so a death mid-dash leaves
// no stale i-frames / stuck-fast on respawn (DashSystem skips dead players via .WithDisabled<Dead>()).
if (SystemAPI.HasComponent<DashState>(entity))
SystemAPI.SetComponent(entity, default(DashState));
if (SystemAPI.HasComponent<CharacterComponent>(entity))
{
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
cc.GroundedMovementSharpness = 15f;
SystemAPI.SetComponent(entity, cc);
}
}
}
}
}
@@ -21,6 +21,9 @@ namespace ProjectM.Simulation
/// <summary>Primary ability fire. InputEvent survives the frame→tick→rollback boundary so a press fires exactly once.</summary>
[GhostField] public InputEvent Fire;
/// <summary>Dodge dash. InputEvent twin of <see cref="Fire"/>: survives the frame-tick-rollback boundary
/// so one press dashes exactly once; read by the predicted DashSystem (MC-1).</summary>
[GhostField] public InputEvent Dash;
/// <summary>Active input scheme this tick (<see cref="InputSchemeId"/>: 0 = mouse/keyboard, 1 = gamepad).
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
@@ -32,7 +35,7 @@ namespace ProjectM.Simulation
var s = new FixedString512Bytes();
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme);
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count);
return s;
}
}
+1
View File
@@ -2537,6 +2537,7 @@ MonoBehaviour:
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
- {fileID: 3885353946372160549, guid: f77a36036567c814496e6c59c42b2082, type: 3}
- {fileID: 3885353946372160549, guid: 31d233e9e507acf45a411f8ab0997bed, type: 3}
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
RingRadius: 16
RingSlots: 10
BaseCount: 4
@@ -0,0 +1,128 @@
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 MC-1 Charger branch in EnemyAISystem. A Husk variant baked with
/// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when
/// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and
/// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots
/// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer.
/// </summary>
public class ChargerTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
return e;
}
static Entity MakeCharger(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponentData(e, new LungeState());
em.AddComponent<EnemyTag>(e);
return e;
}
[Test]
public void Commit_Fires_Even_When_Target_Left_Range()
{
var (world, group) = MakeWorld("ChargerCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6)
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick
group.Update(); // tick 200
var lunge = em.GetComponentData<LungeState>(charger);
Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range).");
Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X).");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(charger).WindUpUntilTick, "The wind-up clears on commit.");
}
}
[Test]
public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window()
{
var (world, group) = MakeWorld("ChargerWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge
em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable
group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "A whiffed lunge is cleared.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<EnemyAttackCooldown>(charger).NextAttackTick,
"An overshoot whiff extends the attack cooldown by the stagger window (the punish window).");
using var tq = em.CreateEntityQuery(typeof(DevTelemetry));
Assert.AreEqual(1u, tq.GetSingleton<DevTelemetry>().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<LungeState>(charger).StaggerUntilTick,
"The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source).");
}
}
[Test]
public void Knockback_Cancels_An_InFlight_Lunge()
{
var (world, group) = MakeWorld("ChargerKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X
group.Update(); // tick 305: knockback (until 315) wins
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick,
"Knockback cancels the in-flight lunge (no two-writer contention on Position).");
Assert.Less(em.GetComponentData<LocalTransform>(charger).Position.x, 0f,
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c31affe7e592820448b105987c883868
@@ -0,0 +1,171 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the MC-1 dash i-frame negation in HealthApplyDamageSystem.
/// The negation compares each DamageEvent.SourceTick against the dashing player's DashState window
/// [StartTick, IFrameUntilTick) using NetworkTick arithmetic (wrap-safe), PER-ELEMENT (unlike the
/// whole-buffer RespawnInvuln / GodMode clears). The drain tick (current ServerTick) is seeded valid
/// but does NOT affect the result — only SourceTick-vs-window does — which is exactly why a melee strike
/// appended a tick earlier in the plain group is still judged against the tick it was AUTHORED.
/// The actual N->N+1 cross-group timing is a Play-validation item (MC-1 review agenda #1), not
/// EditMode-reproducible (plain worlds register systems unsorted, one group, one tick).
/// </summary>
public class DashIFrameNegationTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint drainTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<HealthApplyDamageSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, drainTick);
return (world, group);
}
static Entity MakeDasher(EntityManager em, float hp, uint startTick, uint iFrameUntilTick)
{
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState));
em.SetComponentData(e, new Health { Current = hp, Max = hp });
em.SetComponentData(e, new DashState
{
Dir = new float2(0, 1),
StartTick = startTick,
IFrameUntilTick = iFrameUntilTick,
RecoverUntilTick = iFrameUntilTick, // irrelevant to the negation
});
return e;
}
[Test]
public void Window_Is_Half_Open_StartInclusive_UntilExclusive()
{
// S=100, W=12 -> window [100, 112). drainTick 113 (arbitrary valid; result is tick-of-current independent).
const uint S = 100, W = 12;
var (world, group) = MakeWorld("DashHalfOpen", 113);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, 100f, S, S + W);
var dmg = em.GetBuffer<DamageEvent>(e);
// Distinct amounts so the exact negated set is provable from the surviving Health.
dmg.Add(new DamageEvent { Amount = 1f, SourceNetworkId = -1, SourceTick = S - 1 }); // before -> applies
dmg.Add(new DamageEvent { Amount = 2f, SourceNetworkId = -1, SourceTick = S }); // start -> negated (inclusive)
dmg.Add(new DamageEvent { Amount = 4f, SourceNetworkId = -1, SourceTick = S + 1 }); // inside -> negated
dmg.Add(new DamageEvent { Amount = 8f, SourceNetworkId = -1, SourceTick = S + W - 1 }); // last in -> negated
dmg.Add(new DamageEvent { Amount = 16f, SourceNetworkId = -1, SourceTick = S + W }); // until -> applies (exclusive)
dmg.Add(new DamageEvent { Amount = 32f, SourceNetworkId = -1, SourceTick = S + W + 1 }); // after -> applies
group.Update();
// Applied 1 + 16 + 32 = 49; negated 2 + 4 + 8 = 14. Health 100 - 49 = 51.
Assert.AreEqual(51f, em.GetComponentData<Health>(e).Current, 1e-4f,
"Half-open [S, S+W): S, S+1, S+W-1 negated; S-1, S+W, S+W+1 applied.");
}
}
[Test]
public void Negation_Is_Wraparound_Safe_NetworkTick_Not_Raw_Uint()
{
// Window straddles the uint wrap: [S, 3) with S = uint.MaxValue-2. Avoids SourceTick 0 (the sentinel).
const uint S = uint.MaxValue - 2; // 4294967293
const uint until = 3u; // (S + 6) wrapped past 0
var (world, group) = MakeWorld("DashWrap", 3);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, 1000f, S, until);
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = S + 1 }); // 4294967294, inside -> negated
dmg.Add(new DamageEvent { Amount = 20f, SourceNetworkId = -1, SourceTick = 2u }); // wrapped inside -> negated
dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 5u }); // after -> applies
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = -1, SourceTick = S - 1 }); // 4294967292, before -> applies
group.Update();
// NetworkTick (wrap-correct) applies {5, S-1} = 120 -> 880. A raw-uint compare would
// mis-bucket {S+1, 2} and apply all four (-> 850), which this exact value rejects.
Assert.AreEqual(880f, em.GetComponentData<Health>(e).Current, 1e-4f,
"Wraparound window negates {S+1, 2} and applies {5, S-1} via NetworkTick, not raw uint.");
}
}
[Test]
public void Unstamped_SourceTick_Zero_Is_Never_Negated_FailSafe()
{
var (world, group) = MakeWorld("DashZeroSentinel", 113);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, 100f, 100, 112); // active window
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 25f, SourceNetworkId = -1, SourceTick = 0u });
group.Update();
Assert.AreEqual(75f, em.GetComponentData<Health>(e).Current, 1e-4f,
"An unstamped (SourceTick==0) hit is never i-framed (fail-safe: damage applies).");
}
}
[Test]
public void Negation_Is_Per_Element_Not_Whole_Buffer()
{
var (world, group) = MakeWorld("DashPerElement", 113);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, 100f, 100, 112);
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 105 }); // in window -> negated
dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 200 }); // out window -> applies
group.Update();
Assert.AreEqual(70f, em.GetComponentData<Health>(e).Current, 1e-4f,
"Per-element: only the in-window event is negated; the out-of-window event still applies " +
"(unlike the whole-buffer RespawnInvuln/GodMode clears).");
}
}
[Test]
public void RespawnInvuln_Still_Clears_Whole_Buffer_Before_The_Dash_Loop()
{
// drainTick 100; RespawnInvuln.UntilTick 200 (active). DashState window does NOT cover the hits,
// so if the per-element dash loop ran they would apply — but RespawnInvuln clears the whole buffer first.
var (world, group) = MakeWorld("DashRespawnInvuln", 100);
using (world)
{
var em = world.EntityManager;
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState), typeof(RespawnInvuln));
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
em.SetComponentData(e, new DashState { StartTick = 500, IFrameUntilTick = 512, RecoverUntilTick = 512 });
em.SetComponentData(e, new RespawnInvuln { UntilTick = 200 });
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 100 }); // outside dash window
dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 600 }); // outside dash window
group.Update();
Assert.AreEqual(100f, em.GetComponentData<Health>(e).Current, 1e-4f,
"RespawnInvuln clears the WHOLE buffer (and continues) before the per-element dash loop runs.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(e).Length, "The buffer is drained under RespawnInvuln.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 638643ac1f79886438103e43ece254cf
@@ -0,0 +1,235 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the MC-1 predicted DashSystem (velocity/sharpness OVERRIDE + start/cooldown)
/// and the PlayerDeathStateSystem dash cleanup. The systems carry [UpdateInGroup(PredictedSimulationSystemGroup)]
/// but world-system filtering is ignored when added to a group manually, so they run in this netcode-free world.
/// The override/cleanup logic is fully headless; the input-driven START path uses PlayerInput.Dash.IsSet (the
/// real apply-under-prediction is a Play-validation item).
/// </summary>
public class DashSystemTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick) where T : unmanaged, ISystem
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s.
const float ExpectedDashSpeed = 20f;
static Entity MakeDasher(EntityManager em, float2 facing)
{
var e = em.CreateEntity();
em.AddComponentData(e, new DashState());
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = 15
em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = facing });
em.AddComponent<Simulate>(e); // enabled by default
em.AddComponent<Dead>(e);
em.SetComponentEnabled<Dead>(e, false); // alive
return e;
}
[Test]
public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink()
{
var (world, group) = MakeWorld<DashSystem>("DashOverride", 100);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // as if PlayerControlSystem wrote input velocity
group.Update(); // tick 100: i-frame window [100,112) active
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20).");
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
}
}
[Test]
public void Recovery_Tail_Locks_Movement_And_Restores_Sharpness()
{
var (world, group) = MakeWorld<DashSystem>("DashRecover", 105);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) });
group.Update(); // tick 105: iFrame ended (100<=105), recover active (109>105)
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f,
"Recovery tail locks movement to zero (the punishable window).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"Recovery tail restores sharpness to the default (crisp stop).");
}
}
[Test]
public void After_Window_Restores_Sharpness_And_Leaves_Input_Velocity_Untouched()
{
var (world, group) = MakeWorld<DashSystem>("DashRestore", 200);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(7, 0, 0) }); // input velocity, must survive
group.Update(); // tick 200: window fully elapsed
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"Sharpness restored to default after the dash window elapses.");
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
"Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands).");
}
}
[Test]
public void Dash_Starts_On_Press_When_Ready_And_Sets_Window_And_Cooldown()
{
var (world, group) = MakeWorld<DashSystem>("DashStart", 100);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
Assert.IsTrue(em.GetComponentData<PlayerInput>(e).Dash.IsSet,
"Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption).");
group.Update(); // tick 100
var ds = em.GetComponentData<DashState>(e);
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12.");
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9.");
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
}
}
[Test]
public void Dash_Start_Is_Idempotent_At_The_Same_Tick()
{
var (world, group) = MakeWorld<DashSystem>("DashIdem", 100);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
group.Update(); // starts the dash at tick 100
var first = em.GetComponentData<DashState>(e);
group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window)
var second = em.GetComponentData<DashState>(e);
Assert.AreEqual(first.StartTick, second.StartTick, "Re-running the start tick must not re-trigger the dash.");
Assert.AreEqual(first.IFrameUntilTick, second.IFrameUntilTick, "The window is set exactly once.");
Assert.AreEqual(first.RecoverUntilTick, second.RecoverUntilTick, "The window is set exactly once.");
}
}
[Test]
public void Dash_Is_Gated_By_Cooldown_Until_It_Expires()
{
var (world, group) = MakeWorld<DashSystem>("DashCooldown", 130);
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
// As if dashed at 100: window elapsed by 130, cooldown until 145.
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
em.SetComponentData(e, new DashCooldown { NextTick = 145 });
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
group.Update(); // tick 130 < cooldown 145 -> NO new dash
Assert.AreEqual(100u, em.GetComponentData<DashState>(e).StartTick, "On cooldown: a press does not start a new dash.");
SetServerTick(world, 150); // past the cooldown
pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
group.Update();
Assert.AreEqual(150u, em.GetComponentData<DashState>(e).StartTick, "Past cooldown: a press starts a new dash.");
}
}
[Test]
public void Death_Mid_Dash_Clears_Window_And_Restores_Sharpness()
{
var (world, group) = MakeWorld<PlayerDeathStateSystem>("DashDeath", 100);
using (world)
{
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddComponent<PlayerTag>(e);
em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead
em.AddComponentData(e, new CharacterControl { MoveVelocity = new float3(3, 0, 0) });
em.AddComponentData(e, new DashState { Dir = new float2(1, 0), StartTick = 90, IFrameUntilTick = 110, RecoverUntilTick = 119 }); // in-flight
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.AddComponentData(e, cc);
em.AddComponent<Simulate>(e);
em.AddComponent<Dead>(e);
em.SetComponentEnabled<Dead>(e, false);
group.Update();
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f, "Death zeroes movement.");
}
}
[Test]
public void Rollback_ReSimulated_PreDash_Tick_Gets_No_Override()
{
// DashState is NON-replicated, so prediction rollback does NOT restore it: a re-simulated tick
// BEFORE StartTick still sees the post-press window. The override must include the StartTick lower
// bound or it stomps dash velocity onto pre-dash ticks (dash-start overshoot under real latency).
var (world, group) = MakeWorld<DashSystem>("DashRollback", 95); // serverTick 95 < StartTick 100
using (world)
{
var em = world.EntityManager;
var e = MakeDasher(em, new float2(0, 1));
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // input velocity of the pre-dash tick
group.Update();
Assert.AreEqual(5f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
"A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"A re-simulated PRE-dash tick keeps base sharpness.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c276f751d9353641a1d66d42d7e68ee
@@ -10,6 +10,7 @@
"Unity.Collections",
"Unity.Mathematics",
"Unity.Physics",
"Unity.CharacterController",
"Unity.NetCode",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
@@ -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;
namespace ProjectM.Tests
{
/// <summary>
/// EditMode coverage for the MC-0/MC-1 DevTelemetry counter WIRING (the fun-gate is measured, not argued):
/// DashIFrameNegatedHits + DashState.NegatedCount (HealthApplyDamageSystem), DashesWasted (DashSystem
/// window-close edge, server-gated on the DevTelemetry singleton), and ChargerWhiffPunishesLanded
/// (player-sourced hit inside a Charger's StaggerUntilTick window, scored ONCE per window).
/// </summary>
public class TelemetryCountersTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick, bool withTelemetry)
where T : unmanaged, ISystem
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
if (withTelemetry)
world.EntityManager.CreateEntity(typeof(DevTelemetry));
return (world, group);
}
static DevTelemetry Telemetry(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(DevTelemetry));
return q.GetSingleton<DevTelemetry>();
}
static Entity MakeDashingPlayer(EntityManager em, DashState ds)
{
var e = em.CreateEntity();
em.AddComponentData(e, ds);
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, CharacterComponent.GetDefault());
em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = new float2(0, 1) });
em.AddComponent<Simulate>(e);
em.AddComponent<Dead>(e);
em.SetComponentEnabled<Dead>(e, false);
return e;
}
static Entity MakeStaggeredCharger(EntityManager em, uint staggerUntil)
{
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(LungeState));
em.SetComponentData(e, new Health { Current = 200f, Max = 200f });
em.SetComponentData(e, new LungeState { StaggerUntilTick = staggerUntil });
return e;
}
[Test]
public void Negation_Increments_DevTelemetry_And_DashState_NegatedCount()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemNegate", 113, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState));
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
em.SetComponentData(e, new DashState { StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 101 }); // in-window -> negated
dmg.Add(new DamageEvent { Amount = 6f, SourceNetworkId = -1, SourceTick = 111 }); // in-window -> negated
dmg.Add(new DamageEvent { Amount = 7f, SourceNetworkId = -1, SourceTick = 113 }); // outside -> applies
group.Update();
Assert.AreEqual(2u, Telemetry(em).DashIFrameNegatedHits, "Both in-window hits counted.");
Assert.AreEqual(2u, em.GetComponentData<DashState>(e).NegatedCount,
"NegatedCount written back onto DashState (the wasted-dash signal source).");
Assert.AreEqual(93f, em.GetComponentData<Health>(e).Current, 1e-4f, "Only the outside hit applied.");
}
}
[Test]
public void Wasted_Dash_Counts_Once_On_Window_Close_With_Zero_Negations()
{
// Window fully elapsed at tick 120 (recover ended at 109) and nothing was negated.
var (world, group) = MakeWorld<DashSystem>("TelemWasted", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeDashingPlayer(em, new DashState
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 0 });
group.Update();
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "A dash whose window negated nothing scores wasted.");
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).RecoverUntilTick, "Close edge clears the window (one-shot).");
group.Update();
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "The close edge fires exactly once.");
}
}
[Test]
public void Effective_Dash_Is_Not_Wasted()
{
var (world, group) = MakeWorld<DashSystem>("TelemEffective", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
MakeDashingPlayer(em, new DashState
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 2 });
group.Update();
Assert.AreEqual(0u, Telemetry(em).DashesWasted, "A dash that negated hits is not wasted.");
}
}
[Test]
public void Without_Telemetry_Singleton_The_Close_Edge_Leaves_DashState_Intact()
{
// Client-world behavior: no DevTelemetry singleton -> no zeroing, so rollback re-simulation of the
// tail window stays correct on the client (the server is the only world that clears).
var (world, group) = MakeWorld<DashSystem>("TelemClientNoZero", 120, withTelemetry: false);
using (world)
{
var em = world.EntityManager;
var e = MakeDashingPlayer(em, new DashState
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
group.Update();
Assert.AreEqual(109u, em.GetComponentData<DashState>(e).RecoverUntilTick,
"No telemetry singleton (client world): the window is never zeroed by the close edge.");
}
}
[Test]
public void Player_Hit_On_Staggered_Charger_Scores_Punish_Once()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunish", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150); // stagger window active at 120
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // player hit
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // same drain, same window
group.Update();
Assert.AreEqual(1u, Telemetry(em).ChargerWhiffPunishesLanded,
"A stagger window counts at most ONE punish (ratio to windows-opened stays <= 1).");
Assert.AreEqual(0u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
"Scoring zeroes StaggerUntilTick (the one-shot).");
Assert.AreEqual(180f, em.GetComponentData<Health>(e).Current, 1e-4f, "Both hits still apply damage.");
}
}
[Test]
public void NonPlayer_Hit_On_Staggered_Charger_Does_Not_Score()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishTurret", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150);
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = 119 });
group.Update();
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
"Environment/turret damage (SourceNetworkId=-1) never scores a punish.");
Assert.AreEqual(150u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
"The window stays scoreable for a real player hit.");
}
}
[Test]
public void Expired_Stagger_Window_Does_Not_Score()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishLate", 200, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150); // already over at 200
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 199 });
group.Update();
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
"A hit after the stagger window elapses is not a punish.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e5cfa8e6208aa14ea84b6dc8510bb40
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 48947564183e33a42a7da983e236d8ef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 63b0477da41e2bb4dab1165e6ef6fd4a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0b6fb78380a9249488262ddd760135d2
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+3 -3
View File
@@ -14,9 +14,9 @@ Multiplayer game on **Unity DOTS** (Entities) + **Netcode for Entities** (server
- **Vision** → [[Pillars]] — design pillars & locked decisions · [[Identity]] — the fiction (sci-fi frontier colony)
- **Game Design** → [[Systems_Index]] — per-system design docs
- **Roadmap** → [[Milestones]] · [[Backlog]]
- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-08_Equipment_Slots_Phase1]] · [[2026-06-08_Inventory_Equipment_Progression_Phase0]] · [[2026-06-08_World_Collision_HUD_Scaling]])
- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-027 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-027_Equipment_Slots_Phase1]]
- **Roadmap** → **[[Path_to_Fun]]** (north star — combat-first) · [[Milestones]] · [[Backlog]]
- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-08_Combat_Depth_Direction]] · [[2026-06-08_Equipment_Slots_Phase1]] · [[2026-06-08_Inventory_Equipment_Progression_Phase0]])
- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-028 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-028_Combat_Primary_Verb_Depth_First]] (direction: combat-first)
- **Meta** → [[Documentation_Protocol]] · [[Tags]]
- **Templates** → [[Session_Log_Template]] · [[Decision_Record_Template]]
+12
View File
@@ -91,6 +91,18 @@ Industrial synth, weighty impacts, a low hostile drone for Husk proximity. **Add
| Automation as progression | The colony's surviving Aether-machines compounding output while you raid |
| Persistent base + instanced expeditions | The Engine's safe Aether-bubble (`BaseAnchor`) vs. the blighted **Wild** beyond |
## The braid (locked 2026-06-08) — combat is the verb; the colony is what you fight with and for
> [[DR-028_Combat_Primary_Verb_Depth_First]] · roadmap [[Path_to_Fun]]. No fiction changes — this names how the three pillars **reinforce** instead of sitting side by side as three separate modes (fight, OR build, OR watch automation tick), which is what made it not feel like a game.
The fusion only reads as a game when the pillars **braid into one loop**, and in fiction the braid is already here:
- **Automation makes what you fight WITH.** The colony's rebooted Aether-industry refines raw Aether into the **charges, munitions, turret feed, and upgrades** your abilities and defenses burn — the factory is your war economy (The Riftbreaker).
- **Combat threatens what automation lives IN.** Husk sieges hit the **Engine and its machines**, not just the operators — defending the base is defending the economy, with a real loss beat (They Are Billions / The Riftbreaker).
- **The Sortie feeds both.** Harvesting the Blightfield is the seed the factory needs *and* the act that provokes the siege (Deep Rock Galactic / Core Keeper).
So every fight is fought *with* what the factory made and *for* the base the factory lives in. **Combat is the primary verb; the Awakening Engine and its industry are what give the verb stakes** — the same systems already built, pointed at each other instead of at a ledger.
## Locked narrative choices (2026-06-03)
- **The Echo is ambiguous** — never fully trusted; "by any means"; needs a payoff beat at the goal.
+7 -4
View File
@@ -13,10 +13,12 @@ permalink: gamevault/01-vision/pillars
## Pillars
1. **Action-ARPG combat** — twin-stick, controller-first (LoL / Diablo 4 / PoE2 feel); skill expression over stat-checks.
2. **Co-op base power fantasy** — 24 friends build and grow a shared home base (V Rising feel).
3. **Automation as progression** — production runs itself so play time compounds; the loop rewards setup, not grind.
4. **Server-authoritative & deterministic** — input-only clients, client prediction; the simulation is the source of truth.
> **Combat is the primary verb; base + automation braid around it** (locked 2026-06-08, [[DR-028_Combat_Primary_Verb_Depth_First]]). The fusion of all three is the identity — but as ONE braided loop with a single primary verb, never three co-equal, independently-deep modes. Roadmap: [[Path_to_Fun]].
1. **Action-ARPG combat — the primary verb** — twin-stick, controller-first (Hades / Risk of Rain 2 / Deep Rock Galactic feel); **skill expression over stat-checks** (dodge, read-and-react, a real ability kit). The moment-to-moment the other pillars serve.
2. **Co-op base power fantasy — braids in** — 24 friends defend and grow a shared home base (V Rising / The Riftbreaker feel); the base is what you fight *for* and the siege's stakes.
3. **Automation as progression — braids in** — self-running production that makes what you fight *with* (charges / munitions / turrets / upgrades) and that sieges threaten; rewards setup, not grind (The Riftbreaker / Core Keeper).
4. **Server-authoritative & deterministic** — input-only clients, client prediction; the simulation is the source of truth. Co-op is non-negotiable, so every mechanic stays server-authoritative.
## Locked decisions
@@ -25,6 +27,7 @@ permalink: gamevault/01-vision/pillars
- **Multiplayer (locked):** small co-op **24, client-hosted listen-server** (BinaryWorlds + in-proc IPC; not the experimental SingleWorld host mode), PvE.
- **World (locked):** persistent buildable **home base + instanced/procedural expeditions**.
- **Automation (locked):** **progression accelerator** — self-running production chains; data model designed to grow toward full logistics.
- **Combat-primary + depth-before-breadth (locked 2026-06-08):** combat is the primary braided verb; **no new system until one braided loop is genuinely fun**, and every milestone ends with a play/fun-gate (not just green tests). [[DR-028_Combat_Primary_Verb_Depth_First]] · roadmap [[Path_to_Fun]].
## Related
+13 -1
View File
@@ -12,7 +12,19 @@ Open / candidate work only. **Done items live in [[Milestones]] + their DR / ses
kept to the forward-looking pool — promote an item to [[Milestones|a milestone]] when committed, then drop it here).
Last decluttered 2026-06-08 (removed all shipped `[x]` items; their context is preserved in the linked records).
## NEXT — Inventory · Equipment · Progression (roadmap [[DR-026_Inventory_Equipment_Progression_Foundation]] / [[DR-027_Equipment_Slots_Phase1]])
## NEXT — Combat-depth track (the fight, made fun)
**The forward plan is [[Path_to_Fun]]** (direction [[DR-028_Combat_Primary_Verb_Depth_First]], refined 2026-06-08): combat is the primary braided verb; **depth-before-breadth**, with a **falsifiable fun-gate per milestone**. The roadmap is split into a **committed Path A** and a **provisional, NOT-scheduled Path B**, with a **mandatory logged Decision Gate** between them.
- **Path A — the committed scope (a shippable game-with-a-point):** MC-0 instrument the box → MC-1 dash + committed-punishable Charger → MC-4 melee cone → *[Demo A: the Duel]* → EB-1 machines can die (structure loss-state) → EB-2 the felt spend (turret ammo from the factory) → *[Demo B: the Loop]* → END-1 a losable Core → END-2 final siege / win-lose → **[Decision Gate — log ship-vs-continue]**.
- **Status 2026-06-09: MC-0 + MC-1 CODE-COMPLETE, fun-gate pending** ([[2026-06-09_MC1_Implementation]]) — 259/259 EditMode + clean netcode Play sessions + post-build adversarial review (2 confirmed findings fixed); the operator feel pass (dash snap test, telegraph read, Charger feet) + the bench (timed vs spam) + friend read are the open gate. MC-4 starts only after the MC-1 gate passes.
- **Path B — provisional (pick ONE after the gate, re-estimate first):** MC-2 ranged + swarm + mix-director · MC-3 pure juice · MC-5 downed/revive · MC-6 multi-slot kit · EB-3 base repair · EB-4 tool-gated harvest (braided) · EB-5 craft combat power · END-5 14p scaling + NG+. *(END-3 Echo narrative + END-4 content-treadmill are CUT until Path A is fun and the operator wants to author.)*
Every milestone ends with a **play/fun-gate**, not a test count. **Path A forks are LOCKED** (2026-06-09 — [[DR-029_Path_A_Fork_Locks]] · [[Path_to_Fun#Locked decisions (Path A)]]); Path B forks stay open, locked via the same present-the-forks ritual at the Decision Gate.
## PAUSED — Inventory · Equipment · Progression (roadmap [[DR-026_Inventory_Equipment_Progression_Foundation]] / [[DR-027_Equipment_Slots_Phase1]])
> Paused 2026-06-08 ([[DR-028_Combat_Primary_Verb_Depth_First]]): Phases 24 are more breadth on systems whose payoff is combat power — resume once the fight is fun. Phases 01 shipped and stand.
Phases 0 (inventory backbone) + 1 (equipment slots) shipped 2026-06-08. Remaining phases, in order:
+4 -1
View File
@@ -33,4 +33,7 @@ permalink: gamevault/06-roadmap/milestones
| **— 2026-06-08 World collision + HUD scaling** | Restore world collision (lost when DR-025 made the world cosmetic) + fix HUD scaling in Play. | ✅ Done 2026-06-08 — subscene `Environment`-layer boundary ring + landmark colliders (`WorldCollisionConfig`; player blocked via the layer matrix; enemy `CollisionWorld.SphereCast`); HUD ConstantPhysicalSize→ScaleWithScreenSize. [[2026-06-08_World_Collision_HUD_Scaling]] |
| **— 2026-06-08 Inventory · Equipment (Phase 0 + 1)** | Expand combat + harvesting into per-player inventory + equipment slots that grant abilities/effects, built on the existing data-driven spine (ItemDatabase catalog, StatModifier stack, AbilityRef swap). | ✅ Done 2026-06-08 — **Phase 0**: per-player replicated `InventorySlot` buffer + ID-keyed `ItemDatabase` blob catalog + harvest reroute to the personal bag + `G` deposit-to-ledger RPC + read-only HUD panel. **Phase 1**: `EquipmentSlot` (Weapon/Armor/Trinket/Tool), weapon→`AbilityRef.Id` ability swap, gear→`StatModifier` mods (per-slot sentinels), event-driven server `EquipSystem`, click-to-equip HUD. Architecture pre-validated by 5-/4-lens adversarial review; 236/236 EditMode; Play-validated host+client. **Next: Phase 2 tool-gated harvesting** (see [[Backlog]]). [[DR-026_Inventory_Equipment_Progression_Foundation]], [[DR-027_Equipment_Slots_Phase1]] |
Promote items from [[Backlog]] here when committed.
| **— 2026-06-08 Direction: Combat-first + Path to Fun —** | Strategic pivot — combat is the PRIMARY braided verb (base + automation braid around it, not co-equal); **depth-before-breadth** + per-milestone fun-gates; inventory/equipment Phases 24 + automation breadth PAUSED; new **combat-depth track** designed then **refined same day** into a committed **Path A** (MC-0/1/4 · EB-1/2 · END-1/2 = a shippable game-with-a-point) + a provisional **Path B** with a mandatory **Decision Gate** between them (9-agent design + 3-critic refinement workflows). | 🧭 Direction set 2026-06-08 — [[DR-028_Combat_Primary_Verb_Depth_First]] · [[Path_to_Fun]] |
| **— 2026-06-09 MC-0 + MC-1: dash + Charger duel (code)** | Path A's first combat slice: dev-telemetry instrumentation (MC-0) + the i-frame dash vs the committed, whiff-punishable Charger (MC-1) — spec'd by the mandatory pre-code review ([[2026-06-09_MC1_Build_Spec]]). | 🟡 **Code-complete 2026-06-09 — FUN-GATE PENDING** (gate 3 of 3). `DamageEvent.SourceTick` half-open negation · predicted `DashSystem` (sharpness-override blink, no processor edit) · Charger `LungeState` commit/whiff/stagger branch · `EnemyChargerMuscle` ghost in the wave pool · all four DevTelemetry counters live to the client overlay · dash juice + i-frame hit-suppression. 259/259 EditMode; netcode Play sessions clean (live negation, lunge, telemetry pipe verified); post-build 29-agent adversarial review → 2 confirmed findings fixed in-session (rollback lower-bound on the dash override; drain-order pin). Operator: feel pass + bench + friend read. [[2026-06-09_MC1_Implementation]] |
Promote items from [[Backlog]] here when committed. **The forward plan now lives in [[Path_to_Fun]].**
+500
View File
@@ -0,0 +1,500 @@
---
tags:
- roadmap
- design
- combat
- economy
- endgame
- north-star
status: active
updated: 2026-06-08
permalink: gamevault/06-roadmap/path-to-fun
---
# Path to Fun — the north-star roadmap
> The plan to turn an engineering-complete foundation into a game that's fun to play. Direction locked in [[DR-028_Combat_Primary_Verb_Depth_First]]. This is the **forward** plan; [[Milestones]] stays the historical record, [[Backlog]] the loose pool. Living doc: the [Path A contract table](#path-a--the-proven-path-to-a-point-committed) is the only committed scope; everything in [Path B](#path-b--the-forever-track-provisional-not-scheduled) is provisional and re-derived after Path A's fun-gates pass.
## The problem this solves
M0M7 + inventory/equipment built deep, correct infrastructure but a hollow game (operator, 2026-06-08: *"this does not feel like a game"*). Root cause: **breadth-first, correctness-first** development — every milestone proved a system *replicates deterministically*; none proved a loop is *fun*. Combat — pillar #1 — is one projectile and one enemy brain, never once playtested for enjoyment. The four pillars read as four co-equal genres, which a solo dev can't make co-equally deep, and building them breadth-first is *why* there's no fun.
## The fix, in one sentence
Make **combat the primary verb**, braid base + automation around it as stakes and economy, and go **depth-before-breadth**: no new system until one braided loop is genuinely fun, with a **falsifiable play/fun-gate at every milestone**.
## The braided loop (the target)
> You and your friends raid the Blightfield for raw Aether; your automated base refines it into the ammo, charges, turrets, and upgrades you fight with; and you spend them surviving escalating sieges that hit the base you're standing in — so every fight is fought *with* what your factory made and *for* the base your factory lives in.
| Pillar | Today (separate mode) | Braided (one loop) | Real-game model |
|---|---|---|---|
| **Automation** | Harvester→Conveyor→Fabricator → a ledger number nobody feels | makes the things you fight WITH (charges / munitions / turret feed / upgrades) | The Riftbreaker |
| **Combat** | stand-and-click one projectile; free respawn | the verb you spend the economy on; sieges threaten the base/machines, real loss | They Are Billions / Riftbreaker |
| **Base** | a spawn point + a build grid | what you defend and why the economy exists | V Rising / Core Keeper |
Two small-studio games prove the fusion is achievable **and** that it needs ONE primary verb: **The Riftbreaker** (combat-led — a mech defends an automated base) and **Core Keeper** (mining-led — literal conveyors + drills + boss combat, co-op). Neither makes all three co-equal.
## How to read this roadmap (the hard line) ★
The prior roadmap presented breadth as a flat, equally-weighted, fully-estimated list — which is the *exact shape* of the four-co-equal-pillars error that produced the hollow game, reborn as seventeen-co-equal-milestones. This document refuses that shape. It is split into **two physically separate sections with a hard stop between them**:
- **[Path A — The Proven Path to a Point](#path-a--the-proven-path-to-a-point-committed) (COMMITTED):** the minimal critical path to *fight-is-fun + braided-with-stakes + has-a-win/lose-condition***MC-0, MC-1, MC-4, EB-1, EB-2, END-1, END-2** only. Seven milestones. This is the scope. Its estimates, gates, and demos are real. Finishing Path A is a complete, shippable small game with a point.
- **[Path B — The Forever-Track](#path-b--the-forever-track-provisional-not-scheduled) (PROVISIONAL, NOT SCHEDULED):** MC-2, MC-3, MC-5, MC-6, EB-3/4/5, END-5 — depth and breadth that only earn the right to exist once Path A is proven fun. Its estimates are **indicative only and WILL be re-derived** after Path A's fun-gates pass. Do not treat it as a commitment. (END-3 narrative and END-4 content-treadmill are deferred entirely into the [Cut table](#cut--not-yet-anti-breadth-creep) — see why below.)
**The hard stop** ([Decision Gate](#the-decision-gate-mandatory-stop-after-end-2)): after END-2 ships the minimum-game-with-a-point, an **explicit logged operator decision** is required — *ship/share this minimum and stop, or commit to ONE Path B milestone* — and **no Path B milestone may begin until that decision is logged.** A solo dev with no deadline is most at risk of never shipping precisely because the forever-track always offers one more thing; this gate is the teeth of depth-before-breadth at the place it matters most.
**Estimates are coding-time, not calendar-time.** See the [calendar-time conversion](#calendar-time-the-play-budget-assumption) — the unbounded cost is *fun-tuning*, and at a realistic focused-editor budget Path A is a **multi-month** effort, not the ~8-week coding sum a reader would otherwise anchor on.
**Paused until the loop is fun** ([[DR-028_Combat_Primary_Verb_Depth_First]]): inventory/equipment Phases 24 and automation recipe/throughput breadth resume **only braided** (EB-4/EB-5, Path B) — cut any Phase 24 work that doesn't feed the fight ([[Backlog]]).
## The validation-culture change
Green EditMode + server==client stay **necessary, not sufficient** — they were the *only* bar through M7 and that is why the game is hollow. A milestone is **done** only when all three gates pass: **(1)** EditMode green, **(2)** server==client verified in a real netcode Play session, **(3)** the [fun-gate protocol](#fun-gate-protocol) passes — with a friend on the co-op milestones, and the [instrumentation](#instrumentation-extend-the-m8-dev-tools-triad) confirming the feel claim. DR-028's literal sign-off is *"spacing/timing matters and we didn't want to stop"* — but that is a vibe until it is a **counted, falsifiable** metric, so every milestone below carries observable criteria, not "feels good." Keep the netcode/determinism rigor; just stop treating green tests as "done."
---
# PATH A — The proven path to a point (COMMITTED)
> The smallest path that earns the right to even *consider* Path B: a fight that's fun, braided to an economy you feel spending, with a base you can lose and a win/lose condition. **Execution order:** `MC-0 → MC-1 → MC-4 → [Demo A] → EB-1 → EB-2 → [Demo B] → END-1 → END-2 → [Decision Gate]`. Each milestone carries its own fun-gate; none ships until the prior loop is fun.
## Path A — the contract table (committed)
| ID | Name | Track | Risk | Coding-est | Unlocks |
|---|---|---|---|---|---|
| **MC-0** | Instrument the box (dev-overlay readback) | Combat | LOW | ~0.5 d | every fun-gate's numbers |
| **MC-1** | Fight in a Box: the dash + the question | Combat | MEDHIGH · review-gated | ~2.53.5 wk | the duel; the whiff-punish loop |
| **MC-4** | Offense gets a verb: archetype byte + melee cone | Combat | LOW | ~0.751 wk | dash-in→cleave→dash-out; MC-6 spike |
| **EB-1** | Machines can die: the structure loss-state | Economy | MED · review-gated | ~1.52.5 wk | a base you can lose; END-1's Core hook |
| **EB-2** | The felt spend: output → depletable combat resource | Economy | MED | ~11.5 wk | factory→defense pipe; co-op shared spend |
| **END-1** | The base can be lost: a Core with integrity | Endgame | MED | ~46 d | a real lose condition |
| **END-2** | The charge means something: final siege, win/lose | Endgame | MED | ~24 d | a win beat; the minimum point |
*Estimates are solo + Claude **coding-time only**. They are wider than the prior draft because several of these milestones are secretly 23 slices each (see the [secretly-multi note](#secretly-multi-milestones-why-the-estimates-widened)). **Fun-tuning is the unestimated, unbounded cost** — every milestone's real schedule risk is the playtest→tune→replaytest loop, not the code (see [Risk register](#risk-register) R1/R11). That is why every feel-critical value is a live server singleton, not a baked const (see [Tuning-knob surface](#tuning-knob-surface)), and why the [calendar conversion](#calendar-time-the-play-budget-assumption) below turns these into months.*
## Path A — milestones
> Designed 2026-06-08 via a multi-agent pass (5 design lenses grounded in real games + the actual DOTS code → synthesis → 3 adversarial critics, all **go-with-changes**), then re-cut against a ground-truth code audit (see [Verified-vs-corrected build notes](#verified-vs-corrected-build-notes)) and a second critic round (netcode-feasibility · solo-scope-realism · fun/design-coherence — all go-with-changes) whose blockers + majors are folded in below.
### The thesis
Depth = a **dialogue**. Enemies ask distinct, readable questions (a committed lunge to dodge, a bolt to reposition from, a swarm to AoE); the player answers with tools that have skill and **commitment cost**. The keystone is **enemy commitment + a punishable whiff** paired with the **dash** — the dash is the *answer*, a committed lunge is the *question*; neither is fun alone (so they ship together). The repo is well-shaped for this: the predicted CharacterController, the `RespawnInvuln`/`KnockbackState`/`AttackWindup` windowed-tick idiom, the derive-don't-replicate `Dead` gate, the `StatModifier` fold, and a near-complete `CombatFeedbackSystem` juice scaffold are all already proven under prediction.
### MC-0 — Instrument the box (dev-overlay readback) `~0.5 d` · risk LOW
**Goal:** make every later fun-gate *measurable* before spending a friend's time. The M8 dev-tools triad (`DebugCommandRequest` + `DebugOverlay` + `DebugCommandReceiveSystem`) today only **sends** commands and never reads live values back — that gap is why the gates are unfalsifiable.
- **Scope:** add a server-only `DevTelemetry` `IComponentData` (a flat struct of `uint` counters + a few `float` accumulators, **not** `[GhostField]` by default) updated at the stamp sites the later milestones already touch. Surface it to the local overlay via a handful of owner-send `[GhostField] uint`s on the predicted player (read each frame in `PresentationSystemGroup`) **or** a periodic `DebugTelemetryReport` RPC server→client (avoids any ghost-hash change). Add a read-only IMGUI readout block to `DebugOverlay` showing the live counters + derived ratios (negated-hits/dash, whiff-convert %, per-player DPS, hit-stop frames, downed/revive timers).
- **Build notes:** the dev-RPC wire type stays **unconditional** (no `#if` on the struct — the RpcCollection hash must match release/dev peers); `#if UNITY_EDITOR`-gate only the send/receive systems. Add a `SetTuning(op, valueX1000)` `DebugOp` so the operator nudges live singletons (below) from the overlay without leaving Play. Pure editor-only, server-authoritative plumbing — fully Claude-headless.
- **Fun-gate:** N/A (it's the *instrument* of the gates). Done when the overlay prints a live counter that increments during play and a tuning slider changes a singleton mid-session without a recompile.
- **Claude:** all of it solo/headless. **Operator:** nothing.
- **Dependencies:** none. **Kill-risk:** none — but skipping it makes every downstream gate a debate instead of a glance.
### MC-1 — Fight in a Box: the dash + the question it answers `~2.53.5 wk` · risk MEDIUMHIGH · **review-gated**
**Goal:** turn stand-and-click into a bait-and-punish **duel** — a snappy i-frame dash answering ONE **Charger**'s readable, committed, whiff-punishable lunge. This is the genuinely-smallest fun slice. *(The Swarmer moves to MC-2; it answers a different question — see [Boundary judgment](#boundary-judgment-re-cut-mc-1-default-order-mc-4-early).)*
> **This is 34 distinct risky slices, not one** ([secretly-multi](#secretly-multi-milestones-why-the-estimates-widened)): a new predicted `DashSystem` + replication; a Burst-affecting `CharacterProcessor` edit with its own restart/validate cycle; the `DamageEvent.SourceTick` refactor across THREE stamp sites + the negation branch; and a new Charger brain (lunge/stagger/whiff-detection) + telegraph tuning + dash juice. Each needs its own focused-editor Play-validation — hence the widened estimate.
**Scope (named systems):**
- **Dash** (Hades / Hyper Light Drifter): a NEW predicted `DashSystem` in `PredictedSimulationSystemGroup`, **`[UpdateAfter(PlayerControlSystem)]`** (it overrides the unconditionally-written `CharacterControl.MoveVelocity` during the dash window) **and gated `.WithAll<Simulate>().WithDisabled<Dead>()`** — this matches the existing two-ordered-writer pattern (`PlayerDeathStateSystem` zeroes `MoveVelocity` `[UpdateBefore(PlayerControlSystem)]`; `PlayerControlSystem` sets it), so a third *unordered* writer would be a last-writer-wins determinism hazard. A dedicated `Dash` InputEvent (verbatim `Fire` clone: `[GhostField] InputEvent`, reset+Set each frame in `PlayerInputGatherSystem`). `DashState{float2 Dir; **uint StartTick;** uint IFrameUntilTick; uint RecoverUntilTick}` (predicted, **re-simulated from input** — clone `KnockbackState`'s *shape*, not its server-only-ness) + `DashCooldown{[GhostField] uint NextTick}` (`AbilityCooldown` twin). Whole-window i-frames; a short **recovery tail** (Helldivers dive) so a panic-dash is punishable, not spam.
- **Charger** (L4D2 Charger / DRG Menace) — *the keystone*: a longer-telegraph Husk variant that on commit enters a fixed-direction `LungeState` (a `KnockbackState`-shaped **server-only** field applied INSIDE `EnemyAISystem` as the sole position writer, reusing `SweptMove`); direction locks at commit so a sidestep/dash whiffs it into a **stagger/punish window** (detect the wall-stop / overshoot, extend `EnemyAttackCooldown`). The whiff-punish loop IS the skill ceiling.
- **Readable telegraph** (precondition, not polish — mostly RAMP/TUNING of an existing cue): lengthen the Charger's `AttackWindup` ticks to ≥ interp-delay + reaction (~0.450.6 s, ~2836 ticks, not 18); ramp the **existing** `CombatFeedbackSystem` `AttackWindup` cue into a ground-ring/scale-up. `CombatFeedbackSystem` already cues off the `AttackWindup.WindUpUntilTick` edge (a replicated `[GhostField] uint` countdown) — this is a tune, not a new cue.
- **Dash juice** (must be in MC-1, not deferred): afterimage/whoosh + directional camera nudge + i-frame shimmer, edge-detected from `DashCooldown` exactly as the scaffold already edge-detects `AbilityCooldown` for the muzzle flash.
**Build notes (honor at code time):**
- **i-frame fix — the cross-group tick-alignment blocker (review agenda item #1):** This is the HIGH-severity R2 risk, and the "server-only, no client symmetry" framing must NOT be read as "no determinism care needed." The actual subtlety, verified in code: `HealthApplyDamageSystem` is `ServerSimulation`-filtered **but runs `[UpdateInGroup(PredictedSimulationSystemGroup)]`** (and is the *sole* drainer of `DamageEvent`); the melee strike it negates is appended by `EnemyAISystem` in the **plain** `SimulationSystemGroup` `[UpdateAfter(PredictedSimulationSystemGroup)]`**a different group, drained the following tick.** So "is `DashState` active *now* at drain time" is ≥1 tick off from "was the player i-framed when the strike *landed*" — the same class as the documented "predicted-physics group is OrderFirst" and "contact `DamageEvent` drains the following tick" gotchas. The fix: `DamageEvent` is exactly `{float Amount; int SourceNetworkId}` today — add a **non-replicated `uint SourceTick`** and **stamp it at all THREE append sites** (`EnemyAISystem` strike, `ProjectileDamageSystem`, **and `TurretFireSystem`** — an un-stamped `SourceTick=0` aliases tick 0, the same "ready sentinel" hazard `TickUtil` guards). In `HealthApplyDamageSystem`, **skip a `DamageEvent` whose `SourceTick` is in `[DashState.StartTick, DashState.IFrameUntilTick]` inclusive, compared via `NetworkTick.IsNewerThan`/`TicksSince` — NEVER a raw `uint` compare and NEVER "is-DashState-active-now."** The proposed struct without `StartTick` structurally cannot express this test. **The explicit FIRST agenda item of MC-1's mandatory review is: "at the tick the server drains the melee `DamageEvent`, does the server-side `DashState` i-frame window (compared via `SourceTick`) correctly cover the strike that was appended in a later group the previous tick?"** *(This same `SourceTick` field de-risks the Spitter, structure damage, revive-invuln, and weak-point stamps.)*
- **prediction-reconciliation flicker (presentation note — acceptable, not a bug):** because `Health.Current` is a `[GhostField]` and the player is predicted, a successful **server-only** i-frame dash yields a brief health-bar/prediction flicker on the owning client (the client predicts the hit; the server's authoritative non-damaged `Health.Current` corrects on the next snapshot). This is **acceptable — no desync, server-authoritative** — and must be NOTED so it isn't read as "flaky i-frames" in the very playtest that gates the track, AND so MC-3's hit-flash/`CombatFeedback` is **not** edge-fired on the corrected-away phantom hit.
- **dash-feel fix (Burst-affecting blocker):** `CharacterControl` has only `MoveVelocity`; `CharacterProcessor.HandleVelocityControl` lerps `RelativeVelocity` toward it at `CharacterComponent.GroundedMovementSharpness` (default 15) via `StandardGroundMove_Interpolated` → a flat dash `MoveVelocity` **ramps** ("walk faster"). Fix = raise `GroundedMovementSharpness` for the window **or** write `characterBody.RelativeVelocity` directly inside the processor. **This is a Burst-affecting edit to the predicted processor — do it FIRST, focused editor, Burst-off for the session, expect a restart, Play-validate before building on top** (the stale-binary / ICE hazard; [Risk register](#risk-register) R3).
- **input binding:** don't bind `Dash` to `Space``keyboard.spaceKey.isPressed` is part of the kbm-active scheme sentinel in `PlayerInputGatherSystem`. Use a dedicated action fed symmetrically into device-active detection.
- **replication shape:** `DashCooldown` mirrors `AbilityCooldown`/`RespawnInvuln``[GhostField] uint` scheduled via `TickUtil.NonZero`, compared with `NetworkTick.IsNewerThan` (never raw `uint <`). `DashSystem` gates as above, deterministic/idempotent, no wall-clock; the InputEvent (not a held bool) ensures one dash per press across the frame→tick→rollback boundary.
**Fun-gate (falsifiable):**
- **BENCH METRIC (timed vs spam):** in a fixed 10-lunge bench, a player who dashes ON the telegraph takes **≥70% fewer Charger hits** than a player who dashes on cooldown-spam; MC-0 prints both hit-counts (`dashIFrameNegatedHits`, `dashesWasted`). Spam-dash must demonstrably leave the player in the `RecoverUntilTick` tail when the real lunge lands.
- **WHIFF-PUNISH CONVERTS:** after a dodged lunge the Charger is staggered long enough for a free hit — `chargerWhiffWindowsOpened` vs `chargerWhiffPunishesLanded` > 50% on a skilled run; free-hit window ≥ one attack interval in ≥8/10 dodges.
- **READABILITY UNDER LATENCY:** at simulated ~100 ms interp delay, the tester reacts to the **tell**, not the motion — the dash starts before the Charger's position has visibly committed. If the windup must be *shortened* to feel fair, that is a fail of the band — re-tune, do not ship.
- **SNAP TEST:** the dash covers full distance in its i-frame window with no visible ramp ("blink," not "walk faster") — `RelativeVelocity` reaches dash speed within 12 ticks.
**Tuning knobs:** Dash distance / i-frame window / recovery tail / cooldown / sharpness-override; Charger windup / lunge speed / stagger window — all **live server singletons** with the defaults in the [Tuning-knob surface](#tuning-knob-surface). (Recovery tail is the most-tuned value in the track.)
**Open questions:** Does the dash commit to its direction or keep `PlayerAimSystem` facing free? Whole-window i-frames vs active-frames-only (recommend whole-window v1 — more forgiving, simpler). Charger as a new prefab variant or a brain-discriminator byte on the existing Husk? *(The MoveVelocity-writer fork is now decided by the build note: `DashSystem` `[UpdateAfter(PlayerControlSystem)]` + `.WithDisabled<Dead>()`.)*
**Claude:** all code — `DashSystem`, `DashState`/`DashCooldown`, `DamageEvent.SourceTick` + all THREE stamp sites + the tick-windowed negation branch, the Charger brain branch in `EnemyAISystem`, Dash input wiring, dash-juice hooks, EditMode tests (dash-window negation across the cross-group tick boundary, Charger commit/stagger, a tunnelling-style regression on i-frame tick coverage). **Operator:** the Burst-affecting `CharacterProcessor` edit on a FOCUSED editor (+ likely restart); ALL feel tuning; running the bench + the friend-read at Demo A; **owning the mandatory netcode review** (agenda item #1 above).
**Dependencies:** MC-0 (so the bench is measurable). **Kill-risk:** the dash doesn't FEEL like a blink (the sharpness override is make-or-break) OR the telegraph is unreadable under latency OR the i-frame negation mis-aligns across the group boundary and reads as "flaky/cheap" — any one collapses the duel into spam/RNG and nothing downstream matters. **MC-1 is the kill-switch for the whole project**: if its gate fails after a real tuning pass, STOP and re-cut combat — do not build on an unfun core.
### MC-4 — Offense gets a verb: ability ARCHETYPE byte + melee cone `~0.751 wk` · risk LOW
**Goal:** stop offense being "auto-aim and hold." A byte-dispatched ability archetype with an instant short-range **melee cleave** that makes attacking a *positioning* decision (dash-in → cleave → dash-out). **Runs second, right after MC-1** — verified-low-risk and kills the second-most-felt hollowness; see [Boundary judgment](#boundary-judgment-re-cut-mc-1-default-order-mc-4-early). *(Also the de-risking spike for MC-6's archetype dispatch.)*
- **Archetype byte** on `AbilityDefBlob`/`EffectiveAbilityStats` (Projectile=0 keeps today's path) + a `switch` in `AbilityFireSystem` (stored as **byte** per the Burst cross-assembly enum rule; baked, no replication).
- **MeleeCone / cleave (byte=2):** server-side select all living enemies in a cone around `PlayerFacing.Direction` (**reuse `AutoTarget.Resolve`'s `dot` vs `cos(halfAngle)` cone math as a collect-all selector**), append `DamageEvent` (`SourceTick`-stamped) + `KnockbackState`. Instant, short-range, higher per-hit. No new ghost, pure server damage. `PlayerFacing.Direction` is already a replicated `[GhostField] float2`.
- **Combo glue with MC-1:** dash-in → cleave → dash-out should read as a single verb. Keep the `switch` shape generalizable to hitscan(1)/cone(2)/aoe(3) for MC-6.
- **Feel-coupling note (the trap):** the cleave's cooldown/range MUST be tuned **relative to the dash** — the dash recovery tail must not strand you mid-cleave, and the cleave range must reward the dash-in. **MC-4 cannot "pass" its gate until MC-1 has actually PASSED its gate, not merely shipped** — a combo grammar tested on a still-mushy dash is untestable, and "MC-4 passes on paper because we never validated the dash" is the R9 breadth-creep reflex wearing a combat hat.
**Fun-gate (falsifiable):** `cleaveTargetsPerSwing` averages **>1.5** during a swarm (you position to line up the cone); the dash-in/cleave/dash-out combo is chosen over the projectile when surrounded in ≥8/10 surround situations (`comboChains` counted); a blind-test watcher can tell cleave from projectile by feel/range alone.
**Tuning knobs:** cone half-angle / range / damage / knockback / cooldown — all live singletons (defaults in the [surface](#tuning-knob-surface)).
**Open questions:** cleave as a separate button (simplest, previews MC-6) vs. temporarily replacing Fire? Own cooldown (enables the dash-cleave-shoot grammar) vs. shared with Fire?
**Claude:** the archetype byte + `AbilityFireSystem` switch, the `MeleeCone` selector (lifting `AutoTarget` cone math), `DamageEvent`+`KnockbackState` append, the second-button input wiring, EditMode tests (cone selection count, byte dispatch, no self-hit). **Operator:** cone angle/range/damage feel tuned *relative to the dash*; the distinct-verb blind-test.
**Dependencies:** **MC-1 (PASSED, not just shipped)** — the dash is the other half of the combo. **Kill-risk:** the cleave feels like a re-skinned auto-attack because the cone/range/cooldown let you stand still — the whole point is it PULLS you into the arena.
> ### Demo A — "The Duel" (after MC-1 + MC-4) — **first friend-playable checkpoint**
> The natural place to first satisfy DR-028's literal *"play it, **with a friend**, and not want to stop."* The Charger as the readable threat, dash + cleave + projectile, one short single siege (the existing `ThreatDirector`/`CyclePhase`/`WaveSystem` already produce one). **Include a lightweight TWO-HUMAN co-op read here** — both players dashing/cleaving the Charger and a small swarm (MPPM or a real friend, **no new systems**) — so the project's FIRST validated-fun checkpoint includes a second human, per the non-negotiable co-op pillar. A duel that's fun solo can be boring or chaotic with two; discovering that here is cheap, discovering it at Demo B (5+ milestones later) is not. **This demo's pass/fail is the green-light for the rest of Path A:** if two friends want to keep dueling, the thesis is validated; if not, re-tune MC-1's feel before spending weeks on the economy braid. *(Interdependence isn't designed yet — that's MC-5 in Path B; this read only asks "is two-player combat fun and readable," not "do they need each other.")*
### EB-1 — Machines can die: the structure loss-state `~1.52.5 wk` · risk MEDIUM · **review-gated**
**Goal:** make a built structure destructible so a siege can actually take something from you — the stakes the whole economy is for. *(This is also END-1's mechanical sibling — EB-1 destroys peripheral machines; END-1 adds the losable Core. See [the interleave note](#where-the-economy-braid--endgame-slot).)*
> **This is 56 slices, not one** ([secretly-multi](#secretly-multi-milestones-why-the-estimates-widened)): a ghost-hash-changing `[GhostField]` (re-bake of EVERY structure prefab), an `EnemyAISystem` targeting rewrite, a `HealthApplyDamageSystem` death-branch change, a cross-group production-ordering gate, a `SaveData` v3 migration, AND the loss-juice the milestone itself says IS the milestone — each with its own Play-validation. Hence the widened estimate.
- **Scope:** add `[GhostField] float Health` + non-replicated `float MaxHealth` to `PlacedStructure` (or a sibling `StructureHealth` on the structure ghost) — the **only net-new replicated state in the EB track**. Bake `MaxHealth` per-type from `StructureCatalogEntry` (additive column). Add an `EnemyTarget`/`Damageable` tag so `EnemyAISystem` can pick structures as targets; extend its nearest-target snapshot to include structures under a tunable aggro rule, reusing the existing `AppendToBuffer(DamageEvent)` site verbatim (`SourceTick`-stamped, per MC-1). Extend `HealthApplyDamageSystem`'s death branch (it already `DestroyEntity`s `EnemyTag`/`TrainingDummyTag` at HP≤0) to the structure tag — occupancy auto-frees because `BuildPlaceSystem` derives it from live ghosts. Client-only loss juice (flash/debris/SFX) edge-detected from the ghost prune — a pruned structure ghost = a destroyed structure (the dict-prune-is-a-kill idiom enemies use).
- **Build notes:**
- `PlacedStructure` is an ownerless **interpolated** ghost — `Health` as a `[GhostField]` replicates server→all-clients with NO `OwnerSendType`/`GhostOwner` (server mutations just propagate, exactly like `StorageEntry` on the ledger). `PlacedStructure.Type` is currently the **only** `[GhostField]` on the structure ghost, so adding `Health` **re-hashes the ghost → MANDATORY re-bake of turret + Harvester/Fabricator/Conveyor + Wall/Pylon prefabs** (budget this as the slice's structural cost).
- **Cross-group ordering (the trap EditMode can't catch):** structure death runs in `HealthApplyDamageSystem` (predicted group); production runs in `Harvester/Conveyor/Fabricator` systems (plain group `[UpdateAfter(PredictedSimulationSystemGroup)]`). "Damage-before-production so a structure killed this tick cannot also produce this tick" is an ordering that **spans the predicted/plain boundary** — exactly the silently-ignored-`UpdateBefore` + invisible-cycle hazard CLAUDE.md documents (it throws only at world-creation/Play, never in EditMode). **Gate production on a live-`Health>0` read** rather than relying on cross-group `[UpdateBefore]`, and Play-validate the ordering.
- Death is structural — batch through the existing `HealthApplyDamageSystem` ECB, at-most-once destroy per tick (a structure could take a turret hit AND a Husk hit the same tick). Structures don't predict, so a server-only next-tick `DamageEvent` drain is fine.
- **`SaveData` v3 must persist structure `RemainingHealth`** alongside the existing `RemainingTicks` cooldown, or `BaseRestoreSystem` brings bases back at full HP. **Assert-then-verify** the live `SaveData.CurrentVersion` (=2 today, `SaveData.cs:53`) and its serialized field set (`Goal`/`Ledger`/`Structures`/`StructureIo`) in code at EB-1 time before choosing the additive v3 bump.
- **Fun-gate (falsifiable — observable proxies, not inferred motive):** in a live Host+client siege a Husk the team fails to intercept visibly walks to a placed turret and **destroys it** — you watch it explode and the cell is now empty/rebuildable; **in run N+1 the team places ≥1 defensive structure at (or guarding) the cell where the breach happened in run N (observed)** — i.e. the loss demonstrably changed how they build, not just how they fight; server and all clients agree on which structures are gone (no ghost-structure desync, `execute_code` diff).
- **Tuning knobs:** `StructureCatalogEntry.MaxHealth` per type (baked — Turret 200 / machines 120 / Conveyor 60 / Wall 400 / Pylon 150); the siege structure-vs-player aggro rule (server singleton, live); Husk-vs-structure damage multiplier (server singleton, default 1.0).
- **Open questions:** **(core feel fork — [locked](#locked-decisions-path-a))** Husks PREFER structures (They Are Billions — swarm the base) or PREFER players (DRG — hunt you) with structures as collateral? — changes whether the base is a fortress or bait; live server-singleton knob. EB-1 ships **peripheral-only**; the base-anchor-as-lose-condition is a deliberate END-1 fork. Persist structure HP in `SaveData` v3 or boot-full each session?
- **Claude:** the `StructureHealth` component + `[GhostField]`, the `EnemyAISystem` target-extension, the `HealthApplyDamageSystem` death-branch edit, the production live-Health gate, the `SaveData` v3 field, EditMode coverage (structure takes damage → dies → cell frees; save round-trips HP); validate server==client structure-destroy + the cross-group ordering via `execute_code`/Play. **Operator:** the target-preference fork (fortress vs. bait), the anchor-as-lose-condition decision, **owning the mandatory netcode review** (the `[GhostField]` re-hash + the cross-group production-ordering race), and the play-gate (does losing a machine FEEL like a loss?) watching a real siege.
- **Dependencies:** MC-4 (a fun fight is the thing the stakes amplify). **Kill-risk:** a destroyed machine reads as a silent despawn (no weight, no "oh no") — the loss is mechanically present but emotionally absent and the braid's stakes evaporate. The juice + the target-preference tuning ARE the milestone, not optional polish.
> **Note on EB-1's siege director:** EB-1 deliberately does **not** ship the MC-2 enemy-mix director (that's Path B). It runs against the *existing* `ThreatDirector`/`CyclePhase`/`WaveSystem` single-siege output. A single readable Charger-led siege is enough to prove "a siege can destroy what you built"; the weighted mix is depth layered on later.
### EB-2 — The felt spend: automation output → a depletable combat resource `~11.5 wk` · risk MEDIUM
**Goal:** turn the ledger number into combat power you NOTICE spending and running out of — close the automation→combat loop. **The braid's keystone is EB-1 + EB-2 together:** a turret you FED from your factory's output, destroyed by a siege you must spend that same output to survive, with a real loss when you fail. If that loop is fun, the braid is proven.
- **Scope:** pick ONE depletable resource to start — **turret AMMO** (cleanest, no player-prediction). A per-turret **server-only** `Ammo` count fed from the shared `ResourceLedger`; `TurretFireSystem` decrements per shot and refuses to fire empty. A server-only reload (fold into `TurretFireSystem`) that pulls munition from the shared ledger when below capacity — so the Fabricator's output literally becomes turret uptime (reuse `StorageMath.Withdraw` + `GetSingletonEntity<ResourceLedger>`). A new munition `ItemId` (e.g. `Charge`/`AetherCell`) so automation produces something whose ONLY use is feeding the fight. Replicate ammo minimally: a **single empty/loaded enableable-or-byte** on the structure ghost (no `[GhostField]` count needed — just the empty edge) so the HUD + a `CombatFeedbackSystem` cue shows a starved turret. HUD readout (`HudSystem` observe-only): the shared munition stockpile + a per-turret empty indicator.
- **Build notes:** keep the spend **server-only** in the plain group — turret ammo is on an interpolated ghost, never predicted; mutate the server-only count, replicate only the empty EDGE. The spend MUST read the **shared `ResourceLedger`** (the untagged global ghost), NOT personal `InventorySlot` bags — co-op coherence (DR-026's latent gap; see [The co-op braid](#the-co-op-braid-the-latent-inventory-gap-stops-being-latent)). Munition production reuses `FabricatorProductionSystem` **unchanged** — author a Fabricator recipe whose `OutResourceId` is the new munition id (data-only); `ProductionMath`'s input-limited catch-up handles it. Do NOT route the munition through the *predicted* ability path yet (that's the player's own ammo — a later, higher-risk fork). Route the new id through the existing `ushort ItemId` space (DR-026, ids >3) — no wire change. Stamp `SourceTick` on the `TurretFireSystem` `DamageEvent` (already required by MC-1).
- **Fun-gate (falsifiable — observable proxies, not inferred motive):** during a siege you watch turrets EAT the munition the factory made and the stockpile readout visibly drops; **in a long siege the turrets go silent (run dry) ≥1× AND a player initiates a feed/build action within ~10 s of a silent turret (counted)** — i.e. the empty state demonstrably drives a behavior; a bigger factory measurably extends how long the base holds (timed, two factory sizes); two players splitting "I build the munition line / I hold the line" clears a fixed siege faster than one doing both (co-op via the shared ledger, **needs a friend** — Demo B).
- **Tuning knobs:** turret ammo capacity + shots-per-reload (baked, cap 30 / reload 10); munition cost per shot (live singleton, 1); the Fabricator munition recipe ratio + period (baked, 2 Aether → 1 Charge); empty-turret behavior — hard-stop vs. degraded slow-fire (live singleton, default hard-stop for clearer feedback).
- **Open questions:** **(fork — [locked](#locked-decisions-path-a))** turret ammo (server-only, safe) vs. the PLAYER'S abilities consuming a charge (stronger braid but touches predicted state — a deliberate later fork the operator green-lights only if turret-ammo proves the loop). Running dry = soft fail (turrets quiet) vs. contributes to the lose condition (ties to END-1)? One munition type or per-defense types? — start with ONE.
- **Claude:** the `Ammo` count + `TurretFireSystem` decrement/refuse-empty, the ledger-fed reload, the new munition id + Fabricator recipe authoring, the empty-state bit + HUD readout, EditMode coverage (fires N then starves; reload pulls from ledger); headless-validate the factory→turret pipe via `execute_code`. **Operator:** the production-vs-depletion balance (the make-or-break feel), the turret-vs-player-ammo fork, confirming the spend READS as the factory powering the defense; **the co-op feed-vs-fight read at Demo B (friend non-negotiable).**
- **Dependencies:** EB-1 (a loss state — otherwise an under-fed base has no consequence). **Kill-risk:** the stockpile never realistically runs dry (over-produced) OR drains so fast the base is helpless — either way the player doesn't FEEL the loop, which is the entire point. The balance tuning IS the milestone.
> ### Demo B — "The Loop" (after EB-2) — **first batched friend session for the braid**
> The first **cohesive vertical slice** that shows the braid, not just a tuned fight: a 515 min arc where you dodge the Charger's lunge and carve the swarm — every hit landing — while your turrets eat the munition your factory made (EB-2) and a siege can destroy what you built (EB-1). This is the demo that proves *"it feels like a game,"* not just *"the fight is fun."* **Needs a human friend** — the feed-vs-fight co-op specialization is the gate, and a friend's availability is an **external scheduling dependency on the critical path** (see [Solo + Claude cadence](#solo--claude-cadence)): pre-schedule it; the spine can block on a calendar, not just on code.
### END-1 — The base can be lost: a Core with integrity `~46 d` · risk MEDIUM
**Goal:** give the siege teeth — Husks that break through attack the Engine Core; if its integrity hits 0 the base is overrun.
- **Scope:** a `CoreIntegrity{[GhostField] int Current, Max}` on the existing GLOBAL CycleDirector ghost (the untagged ghost already carrying `CycleState`/`GoalProgress`/the ledger — **no new ghost, no relevancy work**); baked `Max` via `CycleDirectorAuthoring`, born-correct via `CycleDirectorSpawnSystem` (mirror the `PendingSave`/`GoalProgress` staging). A server-only `CoreDamageSystem` in the plain group `[UpdateAfter(PredictedSimulationSystemGroup)]` — a Husk reaching the Core radius (reuse `BaseGridMath.PlotCenter` + the `EnemyAIMath` in-range check) drains integrity and despawns. A `CoreRestoreSystem` — in Calm the Core regenerates toward Max (a chipped-but-survived Core is a setback you recover from). Lose-edge: `CoreIntegrity.Current<=0` during Siege sets a replicated `RunOutcome{byte=Overrun}`; the host-only persistence layer resolves the loss (see the lose-severity fork). HUD: a client-only base-integrity bar (mirror the `GoalProgress` hex-pip/bar path).
- **Build notes:** `CoreIntegrity` rides the EXISTING untagged ghost — do NOT region-tag it (`SetIsIrrelevant` would hide it cross-region; the shared-global-state rule). Core damage server-only in the plain group; a Husk reaching the Core is at-most-once-per-tick via the ECB destroyed-bitset. `RunOutcome` is a BYTE not an enum; the rollback (if chosen) is HOST-ONLY (persistence is host-authoritative). Any cooldown sentinel routes through `TickUtil.NonZero`. Sample the lose-edge once (guard re-firing while at 0 via a `RunOutcome`-already-set check).
- **Fun-gate (falsifiable):** in a live Host+client siege a Husk the team fails to intercept visibly walks to the Engine and the integrity bar ticks down — players reposition to body-block / focus it (**observed: a player abandons farming/repositions to defend the Core ≥1×**); in 3 test sieges the players can **name why they lost** (not a coin flip); a chipped-but-survived Core regenerating in Calm reads as "we got hurt but we're okay." **Crucially: re-run a Demo-B-era fun playtest WITH the Core present — if the fight is still mushy, STOP and fix the fight first.**
- **Tuning knobs:** `CoreIntegrity.Max` (baked, 100); Core-reach damage per Husk (baked/singleton, ~5 unintercepted Husks = a serious dent but not instant death); Core regen in Calm (baked, ~full recovery over one Calm); lose-severity mode hard-rollback vs. soft-drain (Tuning byte, default the operator's pick — recommend soft for co-op forgiveness).
- **Open questions:** **(lose-severity fork — the biggest, [locked](#locked-decisions-path-a))** hard rollback-to-autosave (clean, pillar-true, but a group loses minutes) vs. SOFT loss (Husks breach, drain a chunk of the shared ledger / damage structures, then the siege ends — no rollback, the base persists wounded; more co-op-forgiving, avoids save-corruption risk)? Does `CoreIntegrity` persist in `SaveData` v3 (a wounded base stays wounded) or boot full? Does a breach destroy placed structures (EB-1's job — recommend Core-bar-only here)?
- **Claude:** the component, the 3 server systems, the HUD bar, the lose-resolution wiring (rollback or soft-drain per the fork), EditMode coverage (Core drains under siege, regens in Calm, lose-edge fires once). **Operator:** the fun-gate playtest (is defending the Core engaging?), the diegetic Core/Engine placement, the lose-severity decision.
- **Dependencies:** EB-1 (the structure loss-state is the natural sibling; both make the siege threatening). **Kill-risk:** if defending the Core isn't fun, nothing downstream matters — a losable base only amplifies an already-good fight; shipping it before the fight earns it actively hurts.
### END-2 — The charge means something: the cap arms a final siege, win or lose `~24 d` · risk MEDIUM
**Goal:** at `GoalProgress.Charge>=Target` the Engine begins opening the Wellspring — a final, larger escalating siege — and surviving it fires the WIN beat. The meter is no longer a number that stops at 10. **This is the minimum "the game has a point."**
- **Scope:** a server-only `GoalReachedSystem` that, on the `Charge>=Target` edge (currently UNHANDLED — `CyclePhaseSystem` increments past it forever with no clamp), arms a FINAL siege via the existing `ThreatState.PendingSiegeSize` entry point (bigger size + a distinct telegraph) and sets a replicated `RunPhase{byte=FinalDefense}`. The WIN-edge — surviving the final siege during `RunPhase.FinalDefense` sets `RunOutcome{byte=Victory}`, fires the ending event (subtitle/banner first), and for the minimum simply flips into "keep playing, the base is yours" (the endless/NG+ curve is END-5, Path B). A single client-only WIN/LOSS banner in `HudSystem` (observe `RunOutcome`; reused by END-1's overrun banner). **Zero net-new writing** for the minimum (placeholder banner text), **zero new ghosts.**
- **Build notes:** arm the final siege through the EXISTING single entry point — do NOT add a parallel siege path (`CyclePhaseSystem` stays the sole `WaveState` writer, DR-017's atomic Calm→Siege seed). `RunPhase`/`RunOutcome` are BYTES, single-writer, server-decided. The banner is client-only observe-only. Guard the `GoalReached` edge so it arms EXACTLY ONCE (a `RunPhase!=Normal` guard) — and **clamp the currently-uncapped `Charge`.** If cadence moves to Aether-deposited (the fork below), keep it a server-only single writer (avoid co-op double-count).
- **Fun-gate (falsifiable — countable behavior, not vibe):** a grinding team sees the meter approach full and the Engine telegraphs the Wellspring opening; **the team treats the final siege differently from a normal one (observed: deliberate pre-siege prep — repositioning, topping up munition, a callout — before the final wave, ≥1 countable action)**; losing it stings but the continue means "try again," not "over forever"; winning produces a "we did it" moment even with placeholder text. The final siege is visibly larger/distinct (`liveEnemyCount` peak measurably exceeds a normal siege).
- **Tuning knobs:** `GoalProgress.Target` (baked, currently 10 — the run-length knob); final-siege size + escalation multiplier (server singleton, ~23× normal); charge-per-source (Tuning, +1/siege).
- **Open questions:** **(charge cadence — highest-leverage, [locked](#locked-decisions-path-a))** sieges-survived (combat-only, ships today — the only writer is `CyclePhaseSystem` +1/survived-siege) / Aether-deposited-at-the-Engine (economy braid) / both? Recommend siege-survived first (ships without an economy dependency), both long-term. **(win-resolution — [locked](#locked-decisions-path-a))** WIN = endless/NG+ (END-5) vs. a hard ending + credits + free-play? — the minimum is "keep playing"; the fork is decided at the END-2/Decision-Gate boundary. One big final wave vs. a multi-stage gauntlet + boss? — a big escalating wave for the minimum (boss is Cut).
- **Claude:** the `GoalReached` arming + `Charge` clamp, the win/loss-edge bytes, the banner, EditMode coverage (cap arms the final siege once; win-edge fires once; loss falls through to continue). **Operator:** the climax-feel fun-gate, the charge-cadence fork, the win-screen writing (or accept placeholder), the win-resolution call at the Decision Gate.
- **Dependencies:** END-1 (a losable base — otherwise "win" is hollow because you could never have lost). **Kill-risk:** the final siege is indistinguishable from a normal siege → the "win" is anticlimactic and the meter stays meaningless; it must escalate visibly.
### The Decision Gate (MANDATORY STOP after END-2) ★
END-2 completes Path A: a fight that's fun, braided to a felt economy, with a base you can lose and a real win/lose condition — **a complete, shippable small game with a point.** Before ANY Path B milestone (MC-2/MC-3/MC-5/MC-6/EB-3/EB-4/EB-5/END-5) begins, an **explicit operator decision MUST be logged** (a session note / DR):
1. **Ship/share the minimum and stop here**, or
2. **Commit to exactly ONE Path B milestone** — re-deriving its estimate from scratch against the now-known feel, and re-running this gate after it.
**No Path B milestone may start until that decision is logged.** This is the enforcement point of depth-before-breadth: the forever-track always offers one more thing, and a solo dev with no deadline is most at risk of never shipping. The gate forces the question "is the minimum game good enough to put in front of people?" before the unbounded backlog reopens. Path B's table below exists to inform that choice — not to be executed as a sprint. **And before building the chosen Path B milestone, lock its forks first via the fork-locking ritual ([Locked decisions](#locked-decisions-path-a)) — present each fork to the operator and let them decide; never auto-decide a gameplay-design question.**
---
# PATH B — The forever-track (PROVISIONAL, NOT SCHEDULED)
> **Everything below is depth and breadth that only earns the right to exist once Path A is proven fun.** Estimates are **indicative only and WILL be re-derived** after Path A's fun-gates pass and the [Decision Gate](#the-decision-gate-mandatory-stop-after-end-2) is logged — **do not treat this as a commitment or a schedule.** A solo dev with no deadline will not finish seventeen milestones; this section is a menu the operator picks ONE item from at a time, re-deciding after each. The build notes here are real (the netcode homework is done) so that *if* an item is chosen it starts on solid ground — but choosing is gated.
## Path B — indicative menu (re-derive before committing any row)
| ID | Name | Track | Risk | Indicative | Unlocks |
|---|---|---|---|---|---|
| **MC-2** | Mix the questions: ranged + swarm + mix-director | Combat | MED · review-gated | ~11.5 wk | reposition + surround; siege mix table |
| **MC-3** | Every hit lands (pure juice) | Combat | LOW | ~0.75 wk | weight; freeze-frame |
| **MC-5** | The co-op keystone: downed + revive | Combat | MED · review-gated | ~1.52 wk | death-as-crisis; interdependence |
| **MC-6** | The full kit: multi-slot loadout | Combat | HIGH · review-gated | ~56 wk | complementary builds |
| **EB-3** | Base repair: spend to recover the loss | Economy | LOW | ~0.50.75 wk | loss→recover half of the loop |
| **EB-4** | Tool-gated harvest, braided | Economy | LOW | ~0.75 wk | gear-tier → feedstock → fight |
| **EB-5** | Craft combat power: the Fabricator builds your arsenal | Economy | MED | ~1.52 wk | harvest→craft→equip→fight closes |
| **END-5** | 14p difficulty scaling + endless/NG+ | Endgame | MED | ~34 d sys | co-op scaling; a reason to keep the base |
*(END-3 narrative beats and END-4 content-treadmill are NOT in this menu — they are deferred into the [Cut table](#cut--not-yet-anti-breadth-creep) as pure breadth wearing a low-risk-system costume; see the rationale there.)*
## Path B — milestones (provisional)
### MC-2 — Mix the questions: ranged threat + the swarm + the mix-director `~11.5 wk (indicative)` · risk MEDIUM · **review-gated**
**Goal:** add the **reposition** question (Spitter) and the **surround** question (Swarmer), driven by a weighted enemy-MIX band table layered on the EXISTING siege scheduler.
- **Spitter** — *the only genuinely-new netcode in the whole combat track*: a server-spawned **interpolated** enemy-projectile ghost moved by a NEW plain-group `EnemyProjectileMoveSystem` (stores its own `LastStep`; rebuild the swept segment from `cur - dir*LastStep`, **never `SystemAPI.Time.DeltaTime` in a plain-group system**) + swept `EnemyProjectileDamageSystem` (`SourceTick`-stamped, at-most-once `ecb.DestroyEntity`, **tunnelling regression test**). Prefer a **telegraphed ground-puddle** (L4D2 Spitter) over a fast bolt; cap speed.
- **Swarmer:** brain-0 tuned tiny/fast/near-zero-windup, spawned in count, so the dash also answers "don't get surrounded" (and MC-4's cleave answers it *better*).
- **Mix-director (the genuinely-NEW work — corrected):** the siege *scheduler already exists*`ThreatDirectorSystem` (arms sieges, `PendingSiegeSize`, `SiegeTimeoutTicks`, post-expedition retaliation) + `CyclePhaseSystem` (Calm↔Siege) + `WaveSystem` (Lull/Spawning cadence). Do **not** re-derive pacing. The new work is replacing `WaveSystem`'s blind round-robin with a **deterministic weighted band table** (which brain spawns at which point in a siege). **CRITICAL determinism note:** `WaveSystem` overloads `SpawnCounter` as BOTH the prefab index (`SpawnCounter % prefabs.Length`) AND the ring-placement slot (`RingPosition(center, SpawnCounter, slots)`). A naive "replace the modulo" edit will **silently desync enemy ring positions server-vs-client** (a Play-only bug EditMode won't catch). The weighted pick MUST be a **pure function of an integer counter that does NOT alter `SpawnCounter`'s advance**, OR add a separate placement counter; cover it with the determinism test. **REUSE `ThreatConfig`/`ThreatState`.**
- **A within-fight power beat:** a mid-fight `UpgradePickup`/`StatModifier` spike so offense has a rising curve, not pure attrition (both already exist).
**Fun-gate (falsifiable):** a fresh tester, un-coached, dodges the lunge, repositions out of ≥1 Spitter puddle, and breaks up a Swarmer cluster within one 5-min siege (observed). **DODGEABLE-BOLT METRIC:** at the shipped cap speed, a reacting tester avoids the Spitter telegraph/puddle in ≥8/10 attempts under ~100 ms interp — if they can't, the speed is too high (this is the Play-gate, not a spec number). **PEAKS+BREATHERS:** `liveEnemyCount` over the siege shows ≥1 spike and ≥1 lull, not a flat trickle. **POWER MOMENT:** the tester reacts to the mid-fight upgrade ("now I can…") ≥1×/session.
**Tuning knobs:** Spitter speed / puddle radius / dwell / windup; Swarmer count / speed / windup; mix-band weights (baked table + a live multiplier); the existing hot `ThreatConfig`/`WaveDirector` knobs surfaced live.
**Open questions:** **(fork)** puddle (area-denial, slower — recommended) vs. dodgeable bolt? **(decision — [locked](#locked-decisions-path-a))** `SaveData` v3 (=2 today, `SaveData.cs:53`) holds Goal/Ledger/Structures/StructureIo — `WaveState`/`ThreatState` are **NOT** serialized, so a save/load mid-siege resets the wave. **Assert-then-verify** the live version + field set in code at MC-2 time before choosing: accept session-only sieges (cheaper) **or** bump to v3 with `WaveState`+`ThreatState` appended (additive)? Does the mix-director key off siege-progress (Husks spawned this siege) or wave count? (integer counter either way).
**Build notes:** the Spitter is the only new ghost — ownerless interpolated, moved SERVER-ONLY in the plain `SimulationSystemGroup` `[UpdateAfter(PredictedSimulationSystemGroup)]`, stock `LocalTransform` replication, reuse MC-1's `SourceTick`. **Cover the swept hit-detection with a tunnelling regression test.** **Run a lighter design-review** here — it's the track's only new ghost type ([Risk register](#risk-register) R4).
**Claude:** the two enemy-projectile systems + the new ghost (duplicate-an-existing-ghost recipe), the Swarmer baked variant, the weighted mix-band table + `WaveSystem` refactor (preserving `SpawnCounter`'s ring-advance), the mid-fight power-beat wiring, the tunnelling + ring-determinism regression tests; the `SaveData` v3 migration *if* the operator chooses persistence. **Operator:** the Spitter speed/puddle Play-gate; the mix-band weight tuning; the session-vs-persisted decision; owning the lighter review.
**Dependencies:** Path A complete + the Decision Gate logged. **Kill-risk:** the Spitter is un-dodgeable under latency → reads as unfair chip damage instead of a reposition question — the puddle-over-bolt choice de-risks it. Secondary: a botched `SpawnCounter` refactor desyncs ring placement.
### MC-3 — Every hit lands (pure juice) `~0.75 wk (indicative)` · risk LOW
**Goal:** make the now-meaningful exchange *feel* like one — **real freeze-frame** hit-stop, enemy hit-flash, magnitude-scaled emphasis and knockback — all client-only, observe-only. *(MC-3 is kept **pure juice**; the co-op `EnemyStatus` amp lives in MC-5 where interdependence is the theme — see [Boundary judgment](#boundary-judgment-re-cut-mc-1-default-order-mc-4-early). A juice gate shouldn't also have to prove a co-op synergy.)*
- **REAL freeze-frame hit-stop** (37 frames, **local to the killer**, gated on `DamageEvent.SourceNetworkId` / the local-player edge — `_localPlayer` is already resolved in the system) — **genuinely new:** `CombatFeedbackSystem` does FOV-punch (`PrototypeCameraRig.PunchFov`) + per-magnitude shake + kill FOV punch today, **not** a held freeze. Never `Time.timeScale` (corrupts the sim) — hold the camera/anim *presentation* only. **Do NOT edge-fire on a phantom hit** that MC-1's prediction-reconciliation corrects away (see MC-1's reconciliation-flicker note).
- **Enemy mesh hit-flash** (`AnimatedLitShader` emission via per-instance `MaterialPropertyBlock`) — **DE-RISK ON DAY 1:** confirm the shadergraph exposes an emission input before committing the milestone to it; watch shared-material bleed.
- **Emphasis tiers:** size/shake/SFX scale by hit magnitude + kill tier (extend the existing `FeelConfig`-driven scaffold).
- **Live-tunable, damage-scaled directional knockback:** promote `Tuning.Knockback*` (currently compile-time consts) into a server singleton read by `ProjectileDamageSystem`/`EnemyAISystem`.
**Fun-gate (falsifiable):** **WEIGHT BLIND-TEST** — a watcher ranks three hits (tickle / solid / haymaker) by feel alone in ≥8/10 trials. **KILL PUNCTUATES** — a kill is unmistakably louder than a hit; the tester can tell a kill happened without watching the health bar. **NO SIM CORRUPTION** — server==client position/health unaffected by the hit-stop, verified by an `execute_code` diff during heavy hit-stop (`localHitStopFrames` fires only for the *local* killer).
**Tuning knobs:** hit-stop frames per tier (3/5/7); flash color / duration; emphasis size/shake/SFX per tier; knockback speed / duration — all live `FeelConfig`/singleton.
**Open questions:** hit-stop on ALL hits or only kills + big hits? (all-hits feels chuggy in a swarm — recommend kills + a magnitude threshold).
**Build notes:** all juice = client-only managed `SystemBase` in `PresentationSystemGroup` that OBSERVES (the scaffold already is); read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()`. `DamageEvent.SourceNetworkId` already exists — gate the freeze "local to the killer."
**Claude:** freeze-frame hit-stop, emphasis-tier scaling, the `Knockback*` const→singleton promotion, all `FeelConfig` wiring; the hit-flash `MaterialPropertyBlock` IF the emission input is confirmed. **Operator:** confirm the `AnimatedLitShader` emission input (or supply one); all feel tuning; the weight blind-test.
**Dependencies:** Path A complete + Decision Gate; MC-1 (hits must matter before juicing them). **Kill-risk:** hit-flash blocked because the shader has no emission input (then it needs a shader edit — scope creep) — de-risk day 1.
### MC-5 — The co-op keystone: downed + revive `~1.52 wk (indicative)` · risk MEDIUM · **review-gated**
**Goal:** death becomes a shared crisis with a heroic choice — push to revive vs. hold the line. Makes co-op interdependent, not parallel.
- **Downed → Dead** three-state: `Downed` = a derived enableable (the proven `Dead`-from-`Health` idiom). **The full idiom has THREE clauses — carry all three, not just the `Health<=0` derive:** (1) derive `Downed` from `Health<=0`; (2) **bake `Downed` DISABLED** (players spawn up); (3) the derive system must **visit downed entities via `.WithPresent<Downed>()`** to write the enabled bit on a currently-disabled entity (verified: `PlayerDeathStateSystem` uses exactly `.WithPresent<Dead>()` on the baked-DISABLED `Dead`). Rooted + fire-disabled but still present. **Derive-race fix:** `Downed` and `Dead` both derive from `Health<=0`, so replicate the discriminator — add a **`[GhostField] uint DownedUntilTick`** (bleed-out deadline; `RespawnInvuln{[GhostField] uint UntilTick}` is the exact template) so the owner derives both gates locally and doesn't mispredict downed→dead. Authoritative schedule stays server-side.
- **Proximity revive:** a `ReviveRequest` `IRpcCommand` (scalar payload = downed ghostId, `BuildPlaceRequest` template), applied **server-only** in the plain group `[UpdateAfter(PredictedSimulationSystemGroup)]` (rollback would double-apply) with server-side proximity + channel re-validation; bleed-out falls through to the existing `PlayerRespawnSystem` give-up path.
- **Focus-fire priority elite** (healer/buffer aura — DRG Warden / RoR2 Mending; killing it weakens the pack), flagged with a replicated byte.
- **`EnemyStatus` co-op-amp (lives here, not MC-3):** a **server-only byte** (NOT a `[GhostField]`, NOT an enum) stamped at the existing `KnockbackState` site in `ProjectileDamageSystem`, read in `HealthApplyDamageSystem`'s sum loop to amplify summed damage so support+burst out-performs two soloists.
- **Friendly-fire fix:** do **not** add server-only `KnockbackState` to a *predicted* player (it fights prediction → rubber-band). Soft-FF = a `StatModifier` debuff / revive-channel interrupt only; gate raw-HP FF behind a Tuning toggle (default OFF).
**Fun-gate (falsifiable, needs a friend):** at least one **contested revive** (`reviveChannelsStarted` with enemies inside the revive radius) AND one **bleed-out-to-dead** in the same session; a revive attempt OR a deliberate let-them-bleed call in ≥80% of downs. **INTERDEPENDENCE:** a support+burst duo clears a fixed siege measurably faster than two identical soloists (timed; the `EnemyStatus` amp pays off). **PRIORITY BREAK:** the team breaks target to focus the elite when its aura is active in ≥8/10 elite spawns.
**Tuning knobs:** bleed-out / revive-channel / revive-radius / elite-aura radius+strength / `EnemyStatus` amp + duration — all live singletons; FriendlyFire toggle (Tuning, default OFF).
**Open questions:** **RUN THE ADVERSARIAL NETCODE/DETERMINISM REVIEW BEFORE CODING.** Downed = fully rooted or crawl-at-reduced-speed? Does reviving cost a resource (ties to EB)? Elite = new brain or a flagged Husk variant with an aura component?
**Claude (after the review):** the `Downed` enableable (baked-disabled + `.WithPresent<Downed>()` derive) + `DownedUntilTick` `[GhostField]`, the `ReviveRequest` RPC + server handler, the elite aura byte + effect, the `EnemyStatus` amp; EditMode tests for the derive-race, the baked-disabled-bit write, and the revive proximity gate. **Operator:** decide the forks; run/own the review; two-player playtests; channel/bleed-out feel.
**Dependencies:** Path A complete + Decision Gate; the fight must be worth a revive before death-as-crisis means anything. **Kill-risk:** the downed/dead derive-race mispredicts (owner flickers downed↔dead) because the discriminator isn't replicated correctly, OR "downed never triggers" because `Downed` wasn't baked-disabled / the derive didn't `.WithPresent<Downed>()` — both pre-flagged; the review must validate before coding.
### MC-6 — The full kit: multi-slot loadout `~56 wk (indicative)` · risk HIGH · **review-gated**
**Goal:** offense matches the defense and the threat roster — a RoR2 four-slot kit (Primary / Secondary / Utility=Dash / Special) over distinct archetypes, so two players bring complementary builds. **Deliberately last in any combat path** — a kit only pays off once a meaningful fight, threats, feel, and co-op exist to express into.
> **This is 56 weeks, not 34** ([secretly-multi](#secretly-multi-milestones-why-the-estimates-widened)): a 4-slot serializer generalization (re-bake risk) + per-slot `StatRecompute` + classifier ghost-type-SET + 4-input wiring + HUD, each with the mandatory review and rollback-correctness tuning.
- **Slot axis:** generalize `AbilityRef{byte Id}` + `AbilityCooldown{uint}` to a **fixed `Slot0..3` struct** (4 byte ids + 4 `[GhostField]` `NextFireTick` — a fixed struct over a `DynamicBuffer` to keep serializer churn to **ONE re-bake**) + 4 `Fire`-style InputEvents.
- **The real lift — per-slot `EffectiveAbilityStats`:** `StatRecomputeSystem` folds the character-wide `StatModifier` buffer into each slot, kept **unconditional/uncached** every predicted tick (a change-filter goes stale on rollback — the same discipline the single-slot case already uses).
- **Classifier:** generalize `ProjectileClassificationSystem` to a **ghost-type SET** (DR-016 flagged; keep it **non-Burst** — cross-assembly generics + predicted-spawn classification trip Burst ICEs). **Ship hitscan/cone first (no predicted spawn, no classification) so the classifier generalization is deferred until a 2nd predicted-spawn prefab exists. Fill 23 slots before 4.**
- Reuse the MC-4 archetype byte `switch` as the per-slot dispatcher; store all ids/ops as **byte**.
**Fun-gate (falsifiable):** two players run **different loadouts** and the readout shows complementary archetype usage (`slotFires[0..3]` all > 0 for each; not both spamming slot 0); a tester forms a combo grammar (e.g. utility→special→primary) deliberately in ≥8/10 fights; two different loadouts clear a fixed siege faster *together* than two copies of the better single loadout (timed). **NO ROLLBACK STALE:** per-slot `EffectiveAbilityStats` is correct after a forced rollback (server==client per-slot stats, via `execute_code`).
**Tuning knobs:** per-slot cooldowns (live, differentiated); per-archetype base stats (baked blob); slot→input bindings (input asset, operator).
**Open questions:** **MANDATORY ADVERSARIAL DESIGN REVIEW BEFORE `create_script`.** Which 3 archetypes ship first (projectile/hitscan/cone are no-predicted-spawn-friendly; ground-AoE forces the classifier work)? Fixed loadout vs. swappable at the base (swap = EB territory)? Utility hard-wired to Dash, or a free slot?
**Claude (after the review):** the `Slot0..3` generalization, the 4-input wiring, per-slot `StatRecompute` fold, the archetype dispatch per slot, the classifier ghost-type SET when the 2nd predicted spawn lands, the 4-cooldown HUD; EditMode tests for per-slot stat folding and rollback-correctness. **Operator:** own the review; the slot bindings; balancing the four archetypes; the two-loadout co-op timing gate.
**Dependencies:** Path A + MC-2…MC-5 (a fight, threats, feel, co-op to express into). **Kill-risk:** per-slot stats mispredict on rollback → slot stats diverge; OR the classifier generalization trips a Burst ICE — both pre-flagged; the review must validate the serializer + classifier plan before coding.
> ### (Deferred) Demo D — "The Loadout" (after MC-6)
> Two players bring complementary 4-slot kits and feel like different classes. Reads as a game but as *depth on top of* a game that already exists — deliberately last. The operator can self-validate with an MPPM second client for a first pass; not a primary friend checkpoint.
### EB-3 — Base repair: spend to recover what the siege took `~0.50.75 wk (indicative)` · risk LOW
**Goal:** close the loss→recover half of the loop so a destroyed base is a setback you pay to undo, not a dead end. *(Tiny, pure reuse.)*
- **Scope:** a `RepairRequest` `IRpcCommand` (`BuildPlaceRequest` template, scalar payload = target cell/ghost-id) → a server-only `RepairSystem` (plain group, `AbilityUpgradeSystem` owner-map idiom) that spends shared-ledger resources to restore a damaged structure's `Health` toward `MaxHealth`. Reuse the in-place `StorageMath.Withdraw` + affordability check from `BuildPlaceSystem`; cost scales with missing HP (a tunable curve) so a near-dead machine costs more to save than to let die and rebuild — a real economic decision. Optionally a between-siege auto-repair **Mender** structure (additive `StructureType` byte) that slowly heals adjacent structures from the ledger. Wire a repair mode into the existing build palette (reuses `BuildPreviewMath` + the RPC-send pattern).
- **Build notes:** server-only, applied once (NOT predicted — rollback would double-apply). Repair RESTORES EB-1's `[GhostField] Health` — no new replicated field. Cost from the shared `ResourceLedger`. The Mender (if shipped) is a production-class system `[UpdateAfter(PredictedSimulationSystemGroup)]` mirroring `HarvesterProductionSystem`. Validate affordability + a live target BEFORE withdrawing (the commit-in-place rule); reject a repair on an already-destroyed/full structure silently.
- **Fun-gate (falsifiable):** after a siege chews up your base you spend the calm REPAIRING, and "repair this turret vs. build a new one vs. save for ability upgrades" is a real tradeoff (observed: the player chooses repair over rebuild ≥1× and over a different spend ≥1× in one calm); the repair cost makes you protect structures *during* the fight; a base recovers across cycles if you tend it and decays if you don't.
- **Tuning knobs:** repair cost curve (server singleton, default linear 1 Ore / 5 HP, possibly super-linear); Mender auto-repair rate + ledger drain (baked); repair resource type (server singleton, default Ore).
- **Open questions:** manual repair only (agency, a verb — recommended first) vs. auto-Mender vs. both? Full restore vs. partial? — start full.
- **Claude:** the `RepairRequest` RPC + `RepairSystem` + cost-curve math + the optional Mender + build-palette repair mode + EditMode coverage (restores HP, costs ledger, rejects on dead/full target). **Operator:** the manual-vs-Mender fork and the repair-vs-rebuild cost tuning.
- **Dependencies:** EB-1 (structures must have Health), EB-2 (a spend economy the player is engaged with). **Kill-risk:** repair is always strictly cheaper than rebuilding (no decision) OR always more expensive (dead code) — the cost curve must sit in the interesting middle.
### EB-4 — Tool-gated harvest, braided `~0.75 wk (indicative)` · risk LOW
**Goal:** resume inventory **Phase 2** ONLY as a combat-feeding loop — your equipped tool tier sets how fast you can feed the war economy.
- **Scope:** resume DR-026 Phase 2 — `RequiredToolType`/`RequiredToolTier` baked on `ResourceNode`; `ResourceHarvestSystem` gates + **scales** harvest yield by the firing player's equipped Tool-slot tier. **Braid it:** the harvested feedstock flows into the EB-2 munition pipe (better tool → more raw Aether → more Charges → more turret uptime → you fight longer) so the upgrade is felt AS combat power. Reuse the existing optional `ComponentLookup<GhostOwner>` + tier read already in `ResourceHarvestSystem`; the yield-scale is a pure `InventoryMath`/`HarvestMath` multiplier (no new replicated state — Tool is already an `EquipmentSlot` `[GhostField] ItemId`). A small set: 23 tool tiers in `ItemDatabase`.
- **Build notes:** keep the owner-lookup OPTIONAL so owner-less projectiles (the 8 legacy harvest tests) still pass (DR-026's exact constraint); stay `[BurstCompile]`. Tier from the `EquipmentSlot` Tool ItemId → `ItemDatabase` `byte Tier` (baked) — no new field. Gate is a yield MULTIPLIER (soft) by default, not a hard block-to-zero — tunable. Keep the deposit→ledger→munition pipe SHARED (co-op).
- **Fun-gate (falsifiable):** equipping a better tool visibly speeds your sortie haul AND a base measurably holds longer / abilities become affordable downstream — the gather upgrade pays out in the FIGHT, not on a stat screen; "spend on a better tool vs. a better weapon vs. more turrets" is a real loadout-economy fork; a co-op pair can specialize (one tools-up to feed, one fights) and it's strictly better.
- **Tuning knobs:** per-tier yield multiplier (baked — T1 ×1 / T2 ×1.75 / T3 ×3); gate mode soft vs. hard (server singleton, default soft); node `RequiredToolTier` per resource (baked).
- **Open questions:** soft gate (faster with the tool) vs. hard gate (nodes locked behind tiers)? Does tier affect WHICH resources or only speed? — start speed-only.
- **Claude:** the harvest gate + yield-scale, node `RequiredTool` authoring, the tool-tier catalog rows, EditMode coverage (tier scales yield; soft/hard gate; no-owner fallback preserved). **Operator:** the soft-vs-hard fork and confirming the upgrade is FELT in the fight.
- **Dependencies:** EB-2 (the munition pipe the feedstock feeds — **sequencing after EB-2 is non-negotiable**); 23 seeded tiers or EB-5 for climbing. **Kill-risk:** if the faster haul just inflates a ledger nobody feels spending, the tool gate is pure grind — it ONLY works braided to a felt spend.
### EB-5 — Craft combat power: the Fabricator builds your arsenal `~1.52 wk (indicative)` · risk MEDIUM
**Goal:** resume inventory **Phase 3** crafting ONLY for combat outputs — extend the Fabricator to craft weapons/munition/tool tiers. **Lands LAST in any path** (after MC-6) so the kit it crafts FOR exists.
- **Scope:** resume DR-026 Phase 3 braided — extend `FabricatorProductionSystem` (or a sibling `CraftSystem`) to produce ITEMS (weapons, gear, tool tiers, munition batches); the recipe is data (`ItemDatabase` + a recipe row), no new replication. A `CraftRequest` `IRpcCommand` for player-initiated crafting → a server-only system that spends the shared ledger and deposits the crafted item into the requester's bag or the shared store. Tie the tier curve: crafted tool tiers (EB-4) + crafted weapons (the `AbilityRef` a weapon grants via `EquipSystem`, DR-027) + munition batches (EB-2) all come from the same Fabricator economy. Land the additive `SaveData` v3 DR-027 flagged (restore equipment+inventory atomically AND **replay equip** — a plain buffer restore wouldn't re-add the `StatModifier`s; effects are event-driven), now also covering crafted-item progression.
- **Build notes:** crafting reuses the Fabricator's input-limited deterministic catch-up (`ProductionMath`) — an item recipe is a recipe whose output is an `ItemId`, deposited via `InventoryMath.Deposit`; server-only, plain group. Bump `SaveData.CurrentVersion` (Load nulls on mismatch → clean degrade to New Game). `CraftRequest` is a one-off shared-state RPC → reliable, server-only, applied once. Keep recipe content MINIMAL — 23 weapons, 23 tool tiers, 1 munition batch. A crafted weapon sets `AbilityRef.Id` via the EXISTING `EquipSystem` path — no new combat code; it's an equippable `ItemDatabase` row with a `GrantedAbilityId`.
- **Fun-gate (falsifiable):** you craft a better weapon FROM the resources your factory refined, equip it, and immediately fight better — the **full loop (harvest→refine→craft→equip→fight) closes** and you feel each step; the craft-queue decisions (munition now vs. a weapon for next sortie vs. a tool) are a genuine economy game inside the combat game; a late-game base feels like an ARSENAL you built, and co-op players craft complementary kits from a shared factory.
- **Tuning knobs:** per-recipe cost + craft period (baked rows); craft destination — requester's bag vs. shared store (server singleton, default shared store with player-crafted consumables to the bag); tier-gate — which recipes available from start.
- **Open questions:** auto-craft vs. player-initiated vs. both? — start player-initiated. Crafted weapons permanent vs. consumable? — start permanent + equippable.
- **Claude:** the item-crafting recipe extension, the `CraftRequest` RPC + server system, the `SaveData` v3 atomic equipment/inventory/crafting restore with replay-equip, the recipe content rows, EditMode coverage (craft spends ledger → item in bag/store; save round-trips equipment+inventory+crafted progression; equip replay re-adds `StatModifier`s). **Operator:** the auto-vs-manual fork and the play-gate that the full loop FEELS like one game.
- **Dependencies:** EB-2 (the munition economy), EB-4 (tool tiers), DR-027 `EquipSystem`, **MC-6** (slots to express into — why EB-5 lands LAST). **Kill-risk:** crafting content is the classic breadth trap — a deep recipe tree nobody needs because the fight it feeds isn't deep enough. Shipping it before MC-6 re-creates the breadth-first hollowness DR-028 exists to kill.
### END-5 — Difficulty scales for 14 co-op players + endless/NG+ `~34 d sys (indicative)` · risk MEDIUM
**Goal:** make the threat scale to player count (server-only, netcode-flagged) and turn "win" into an optional endless/NG+ curve so a persistent-base co-op group keeps a reason to play after the first victory.
- **Scope:** player-count-scaled siege size — at siege-arm time `ThreatDirectorSystem` **SAMPLES the live connection count ONCE** and scales `PendingSiegeSize` (the existing single entry point) by a baked per-player multiplier (the inert `ThreatConfig.SizePerExpeditionResource * 0` line at `ThreatDirectorSystem.cs:62` is the template — add a `SizePerPlayer`; **SERVER-ONLY**, never a `[GhostField]`). A join/drop does NOT resize the in-flight wave (sampled once at arm) but DOES affect the next; the bounded `SiegeTimeoutTicks` prevents a soft-lock. NG+/endless (END-2's victory flip made concrete) — on victory raise `GoalProgress.Target` + apply a global difficulty step (size/speed/brain-weight multipliers); **the persistent base carries over** (the locked pillar — NOT a roguelike reset); only the threat curve resets upward. HUD: a difficulty/NG+ tier readout (a replicated byte, observe-only).
- **Build notes:** SAMPLE the player count ONCE at siege-arm — never re-read per tick (jitter / breaks the atomic Calm→Siege seed; server-only so no misprediction). Scale through the EXISTING single entry point — no parallel sizing path. Connection count = iterate `NetworkId`-bearing connections server-side. NG+ step + Target raise are server-decided bytes/ints on the director ghost; the base/structures/ledger CARRY OVER. The tier readout is a replicated byte, client observe-only.
- **Fun-gate (falsifiable):** a 4-player session feels appropriately swarmed vs. a solo session (both fun-gate — neither trivial-solo nor impossible-at-4); a player joining mid-session doesn't break the in-flight siege but the next visibly accounts for them; after winning, the NG+ tier gives a returning group a reason to keep their base; the scaling is invisible-but-felt.
- **Tuning knobs:** `SizePerPlayer` multiplier (baked, sub-linear); NG+ Target raise + per-tier multipliers (baked, +50% Target / +15% per tier); the sub-linear curve exponent (Tuning, <1.0 so grouping is rewarded); mid-siege join policy fixed by the sample-once rule (no knob).
- **Open questions:** scaling shape — linear vs. **sub-linear** (recommended) vs. an HP-vs-count tradeoff? NG+ depth — truly endless vs. a capped ladder (pillar leans endless)? Does difficulty also scale Core integrity / siege frequency, or only wave size + brain-weight (recommended)?
- **Claude:** the sample-once scaling, the NG+/endless flip + difficulty-step multipliers, the tier HUD byte, EditMode coverage (size scales with sampled count; mid-siege join doesn't resize; NG+ raises Target). **Operator:** the 1-vs-4 fun-gate, the scaling-curve tuning, the NG+ depth decision.
- **Dependencies:** END-1, END-2 (the win flip NG+ extends), MC-5 (co-op difficulty is incomplete without the shared-crisis loop). **Kill-risk:** naive per-tick live-count scaling that makes siege size jitter or soft-locks on a mid-siege drop — the sample-once-at-arm rule is the load-bearing fix.
---
## Cross-cutting discipline
### Calendar-time: the play-budget assumption ★
The estimates above are **coding-time**, and the single biggest scope-realism distortion is reading coding-time as calendar-time for a one-person team whose throughput is gated by **focused-editor availability** (Burst-affecting edits, Play-validation, all asset/scene work) and **a friend's calendar** (the co-op fun-gates). The document admits fun-tuning is unbounded, then would otherwise present crisp day/week numbers a reader anchors on — the two statements contradict and the crisp numbers win. So, explicitly:
> **Assume ~58 focused-editor hours/week** for Burst edits, Play-validation, solo dry-runs, and fun-tuning, plus a periodic friend session. At that budget, **Path A's ~710 weeks of coding is a ~47 MONTH calendar effort**, not two months — because the coding lane (Claude, largely headless, unfocused-OK) outruns the focused-tuning lane that actually closes each fun-gate, and the gate is what "done" means here. Path B is unbounded by construction; do not sum it.
Naming the budget makes the timeline falsifiable and stops the coding floor from masquerading as wall-clock. If the real weekly budget is higher or lower, rescale — but rescale *calendar*, never pretend the tuning cost is zero.
### Secretly-multi milestones (why the estimates widened)
Three Path A milestones are each genuinely 23 risky slices, each with its own focused-editor Play-validation cycle (and EditMode does NOT catch the ordering-cycle / stale-binary classes — they throw only at Play). Underestimating single-line milestones is how solo roadmaps silently slip 23×, and the optimism is concentrated exactly where the netcode/Burst risk is highest. So:
- **MC-1 (~2.53.5 wk):** new predicted `DashSystem` + replication · the Burst-affecting `CharacterProcessor` edit (own restart/validate) · the `DamageEvent.SourceTick` refactor across THREE stamp sites + the tick-windowed negation · the Charger brain (lunge/stagger/whiff) + telegraph tuning + dash juice.
- **EB-1 (~1.52.5 wk):** a ghost-hash-changing `[GhostField]` (re-bake every structure prefab) · an `EnemyAISystem` targeting rewrite · a `HealthApplyDamageSystem` death-branch change · a cross-group production-ordering gate · a `SaveData` v3 migration · the loss-juice the milestone says IS the milestone.
- **MC-6 (~56 wk):** 4-slot serializer generalization · per-slot `StatRecompute` · classifier ghost-type-SET · 4-input wiring · HUD · the mandatory review + rollback-correctness tuning.
### Boundary judgment: re-cut MC-1, default-order MC-4 early
**MC-1 is the smallest fun slice MINUS the Swarmer.** The dash + Charger + telegraph is a true bait-and-punish duel; the Swarmer answers a *different* question (surround/AoE) the MC-1 kit cannot answer (you can't punish a swarm by dashing through one enemy). It moves to MC-2 where "mix the questions" is the stated goal and MC-4's cleave gives it a real answer — sharpening MC-1's falsifiable claim to exactly *"timed-dash beats spam-dash vs a readable lunge."* **MC-4 is the DEFAULT second Path A milestone** — verified-low-risk (`PlayerFacing` replicated, `AutoTarget` cone math reusable, pure server damage, no new ghost) and it kills the second-most-felt hollowness; giving the player dash-in/cleave/dash-out early makes the harder fights expressible. **MC-3 is kept pure juice** (Path B) so its gate stays clean; the `EnemyStatus` co-op-amplification lives in MC-5 (where interdependence is the theme). A juice milestone whose gate must also prove a co-op damage synergy is two milestones in one coat.
### Where the Economy-braid + Endgame slot
Do **not** run the economy/endgame strictly after the full combat kit — that is the breadth-first trap DR-028 names. But do **not** front-load the full economy either. The shape is *a thin braid thread pulled into Path A, full breadth deferred to Path B*: **EB-1 (machine loss-state) + EB-2 (the felt spend) are in Path A**, right after MC-4, because every combat milestone before stakes is a fight with no consequence beyond your own (free) respawn. **END-1 + END-2** close Path A with a losable base and a win/lose condition. Everything deeper — the enemy-mix director (MC-2), repair (EB-3), tool-gating/crafting (EB-4/EB-5), scaling/NG+ (END-5) — is Path B, chosen one at a time after the Decision Gate. Each EB/END beat keeps its own fun-gate; none ships until the prior loop is fun.
### Verified-vs-corrected build notes (ground-truth audit)
The MC-1…MC-4 + EB build notes were re-read against the actual code, then re-audited by a second critic round. **Verified correct:** `DamageEvent` is exactly `{float Amount; int SourceNetworkId}`; `HealthApplyDamageSystem` is `ServerSimulation`-filtered AND `[UpdateInGroup(PredictedSimulationSystemGroup)]` + the sole `DamageEvent` drainer; `EnemyAISystem` appends in the **plain** `SimulationSystemGroup` `[UpdateAfter(PredictedSimulationSystemGroup)]` (a different group, drained the following tick); `CharacterControl` has only `MoveVelocity` and `CharacterProcessor` lerps `RelativeVelocity` at `GroundedMovementSharpness=15`; `spaceKey.isPressed` is in the kbm-active sentinel; `AttackWindup.WindUpUntilTick` is the `[GhostField]` `CombatFeedbackSystem` already cues off; `RespawnInvuln{[GhostField] uint UntilTick}` is the `DownedUntilTick` template; `PlayerDeathStateSystem` uses `.WithPresent<Dead>()` on a baked-DISABLED `Dead`; `PlacedStructure.Type` is the only `[GhostField]` (so adding `Health` re-hashes); `WaveSystem` overloads `SpawnCounter` as prefab-index AND ring-slot; `ThreatDirectorSystem.cs:62` is literally `SizePerExpeditionResource * 0 // deferred`; `SaveData.CurrentVersion = 2`. **Corrected against code:** (1) the i-frame negation is server-only **but still tick-determinism-critical** — the strike is appended a tick earlier in a different group, so the negation MUST compare `SourceTick` against `DashState`'s stored `[StartTick, IFrameUntilTick]` window via `NetworkTick`, never "is-DashState-active-now"; this is MC-1's review agenda item #1. (2) `DamageEvent` has **THREE** append sites, not two — `EnemyAISystem`, `ProjectileDamageSystem`, **and `TurretFireSystem.cs:95`** — all must stamp `SourceTick`. (3) the MC-2 "wave director" is largely **already built** (Threat/Cycle/Wave systems) — the new work is the weighted MIX table, not pacing — and the refactor must not perturb `SpawnCounter`'s ring-advance. (4) `WaveState`/`ThreatState` are NOT in `SaveData` — sieges are session-only today; persisting them is a **design decision**, assert-then-verify the version at code time. (5) MC-3's hit-stop is **genuinely new**`PunchFov` is an FOV kick, not a freeze.
### Fun-gate protocol
> Operationalizes DR-028's *"play it, with a friend, and not want to stop"* into a repeatable per-milestone checklist with OBSERVABLE, FALSIFIABLE criteria. A milestone is **done** only when all three gates pass: (1) EditMode green, (2) server==client in a real netcode Play session, (3) this fun-gate. Gates 1 and 2 are necessary-not-sufficient — they were the only bar through M7 and that is why the game is hollow.
**The session ritual (every milestone):** (1) **Solo dry-run first** — boot `Game.unity`, open the dev overlay (MC-0), force the exact threat the milestone introduces, play 510 min for *correctness-of-feel* and to read the instrumentation (NOT the fun-gate). (2) **Friend run** — two clients, one full intended arc, no coaching beyond a one-line prompt; the REAL sign-off for the co-op milestones (Demo A's two-human read, EB-2/Demo B, MC-5/Demo C) needs a human friend. (3) **Score the checklist** — every box is a yes/no a bystander could verify by watching the screen + the readout. If any kill-criterion box is no, the milestone is not done — tune or cut, do not advance.
**Universal checklist:** *Did-not-want-to-stop* (a player immediately re-engaged without prompting — pressed deploy/again, or said "one more"); *Readability* (the friend named the threat's intent *before* it resolved — dodged on the tell, not the hit); *No feel-regressions* (hit-stop never touched `Time.timeScale`; no rubber-band on the local player; server==client still holds during juice; the prediction-reconciliation flicker is noted-acceptable, not chased); *Instrumentation agreed with feel* (the readout corroborates the subjective claim). Per-milestone observable criteria are in each milestone's fun-gate field.
**Anti-gaming rule:** the instrumentation **corroborates**, it does not **define** fun. A slice that hits every number but the friend stops after one arc has FAILED the gate. Numbers exist to *falsify a false feel-claim* ("the dash feels skillful" but negated-hits/dash is 0.1), never to override a true negative. **Corollary (no inferred motive in a gate):** every fun-gate box must be a behavior a bystander can SEE — never a "because they remember…" or "they say this is it." Where the prior draft inferred motive, it's been replaced with a countable proxy (EB-1: places a structure at the breach cell next run; EB-2: feeds within ~10 s of a silent turret; END-2: a deliberate pre-final-siege prep action).
### Instrumentation (extend the M8 dev-tools triad)
Built in **MC-0**, the measurement vehicle for every gate. A server-only `DevTelemetry` struct of `uint` counters + `float` accumulators updated at sites the milestones already touch — `dashIFrameNegatedHits`/`dashesWasted` where `HealthApplyDamageSystem` negates against `DashState`'s stored tick window; `chargerWhiff*` in `EnemyAISystem` at the lunge wall-stop/overshoot; `damageDealt` per `GhostOwner` in `ProjectileDamageSystem`/the cleave path; `cleaveTargetsPerSwing`/`comboChains` (MC-4); `localHitStopFrames` (MC-3); the `EnemyStatus`-amp bonus + `reviveChannelsStarted`/`elitePriorityKills` (MC-5); `slotFires[0..3]` (MC-6). Surfaced to the overlay via owner-send `[GhostField] uint`s on the predicted player (cross-player comparison reads both owners off the player query) **or** a periodic `DebugTelemetryReport` RPC (no ghost-hash change). All increments are at near-zero new surface — the sites already exist.
### Tuning-knob surface
> Consolidated table of every live-tunable value, so the operator tunes **defaults-first, live**. `Tuning.cs` consts are compile-time → Burst-inlined → **baked** (a recompile per tweak, Burst-affecting if inside a Bursted system). Feel-critical values are promoted to a **server singleton** (read at runtime → live-tunable in Play via the MC-0 `SetTuning` op; server==client holds because the singleton lives on the server). **Rule of thumb:** *baked* for structural/rarely-tuned values (cooldown sentinels, SourceIds, catch-up bounds, max-slot caps, per-prefab stats); *singleton* for values you tune by ear during a playtest (dash distance/i-frames/recovery, knockback, windups, hit-stop, telegraph lead, depletion rates).
| Knob | Milestone | Path | Baked / Singleton | Default | Notes |
|---|---|---|---|---|---|
| Dash distance (units) | MC-1 | A | **Singleton** | 4.0 | feel-critical |
| Dash i-frame window (ticks) | MC-1 | A | **Singleton** | 12 (~0.2 s) | whole-window; the `SourceTick`-window negation reads `[StartTick, IFrameUntilTick]` |
| Dash recovery tail (ticks) | MC-1 | A | **Singleton** | 9 (~0.15 s) | the punish-the-spam knob — most-tuned in the track |
| Dash cooldown (ticks) | MC-1 | A | **Singleton** | 45 (~0.75 s) | route via `TickUtil.NonZero` |
| Dash movement sharpness | MC-1 | A | Baked const | ~200 (near-instant) | the `GroundedMovementSharpness` override fix |
| Charger telegraph lead (ticks) | MC-1 | A | **Singleton** | 30 (~0.5 s) | ≥ interp-delay + reaction; THE readability knob |
| Charger lunge speed (units/s) | MC-1 | A | **Singleton** | 16 | committed fixed-dir |
| Charger whiff-stagger (ticks) | MC-1 | A | **Singleton** | 36 (~0.6 s) | extends `EnemyAttackCooldown` |
| Husk attack windup | MC-1 | A | **Singleton** (promote `Tuning.AttackWindupTicks`) | 28 | currently baked const 18 |
| Melee-cone half-angle / range / dmg | MC-4 | A | **Singleton** | 45° / 3.0 / 1.6× | reuse `AutoTarget` cone math |
| Cleave cooldown (ticks) | MC-4 | A | **Singleton** | 36 | tuned RELATIVE to the dash recovery tail |
| Structure `MaxHealth` per type | EB-1 | A | Baked (`StructureCatalogEntry`) | Turret 200 / machine 120 / wall 400 | re-bakes structure ghosts |
| Siege structure-vs-player aggro | EB-1 | A | **Singleton** | nearest-structure-then-player | the fortress-vs-bait fork |
| Turret ammo cap / reload | EB-2 | A | Baked | 30 / 10 | per turret type |
| Munition cost per shot | EB-2 | A | **Singleton** | 1 | the spend rate |
| Fabricator munition recipe | EB-2 | A | Baked | 2 Aether → 1 Charge | data-only recipe |
| `CoreIntegrity.Max` / regen | END-1 | A | Baked | 100 / full-over-one-Calm | the base lose-bar |
| Lose-severity mode | END-1 | A | Baked toggle | soft (co-op-forgiving) | hard-rollback vs. soft-drain |
| `GoalProgress.Target` | END-2 | A | Baked | 10 | the run-length knob; clamp the cap |
| Final-siege size multiplier | END-2 | A | **Singleton** | 23× | the climax escalation |
| Spitter projectile speed | MC-2 | B | **Singleton** | 9 (cap) | "dodgeable under ~100 ms" is a Play-gate |
| Spitter puddle radius / lifetime | MC-2 | B | **Singleton** | 2.5 / 90t | telegraphed puddle preferred |
| Wave weighted-band table | MC-2 | B | Baked (data asset) | per-band weights | deterministic; must not perturb `SpawnCounter` ring-advance |
| Wave spike/lull intensity | MC-2 | B | **Singleton** | 1.5× / 0.4× | the pacing knob |
| Swarmer count / windup / speed | MC-2 | B | Baked (per-prefab) | 8 / ~3t / fast | authored stats |
| Hit-stop frames (hit/kill) | MC-3 | B | **Singleton** | 3 / 6 | local to the killer; never `Time.timeScale` |
| Knockback speed / duration | MC-3 | B | **Singleton** (promote `Tuning.Knockback*`) | 8.0 / 8t | DR-028 calls for this promotion |
| Downed bleed-out (ticks) | MC-5 | B | **Singleton** | 600 (~10 s) | the `DownedUntilTick` deadline |
| Revive channel / radius | MC-5 | B | **Singleton** | 120 (~2 s) / 2.5 | exposure-during-channel is the tension |
| Elite focus-fire aura | MC-5 | B | **Singleton** | +25% pack buff | killing it weakens the pack |
| `EnemyStatus` synergy amp | MC-5 | B | **Singleton** | +30% | the duo-synergy knob |
| Friendly-fire mode | MC-5 | B | Baked toggle | OFF (soft-only) | raw-HP FF gated behind this |
| Per-slot fire/cooldown | MC-6 | B | Baked (per-ability) | per-archetype | authored |
| Repair cost curve | EB-3 | B | **Singleton** | 1 Ore / 5 HP | must sit in the interesting middle |
| Per-tier harvest yield mult | EB-4 | B | Baked (tier curve) | T1 ×1 / T2 ×1.75 / T3 ×3 | felt downstream in the fight |
| `SizePerPlayer` multiplier | END-5 | B | Baked | sub-linear (exp <1.0) | sample-once at siege-arm |
| NG+ Target raise / per-tier step | END-5 | B | Baked | +50% / +15% | persistent base carries over |
**Workflow:** ship every singleton with the default above (defaults-first — autonomous polish, consult only on real forks). During a fun-gate the operator nudges singletons live via the overlay's `SetTuning` op, watches the instrumentation, lands a value, and Claude bakes the landed default back into the authoring component afterward. Baked consts change only between sessions (a Burst-affecting one demands a focused editor).
### Solo + Claude cadence
Two lanes run in **parallel**. **Systems lane (Claude-led, mostly headless):** new `IComponentData`/`ISystem`, RPC wire types, the `SourceTick`-negation fix, swept hit-detection + tunnelling regression tests, the wave band-table integer math, the MC-0 instrumentation, all EB/END server-only systems, EditMode tests — via the MCP script-edit path (`apply_text_edits`/`create_script`, one edit per call), `refresh_unity scope=scripts`, EditMode runs. Advances overnight on an **unfocused** editor *as long as it avoids Burst-affecting edits to already-Bursted systems*. **Art/content lane (operator-led, focused editor):** enemy prefab variants via `EnemyRigTools` (GUID-preserving), telegraph/dash VFX/SFX in the `CombatFeedbackSystem` scaffold, hit-flash shadergraph confirmation, biome dressing, ability/archetype blob authoring.
**HARD-requires the operator + a FOCUSED editor:** any Burst-affecting edit to an already-Bursted system — the canonical case is the MC-1 `CharacterProcessor` sharpness/`RelativeVelocity` write (and the MC-6 per-slot recompute). Editing a Bursted ISystem's query set unfocused can leave a STALE binary → a runtime `InvalidOperationException` from an unrelated `GetSingleton` (Burst reports the OLD line); a Burst ICE corrupts the cache ("not a known Burst entry point" + slow managed fallback) — **fix is an editor restart**, not a domain reload. Cluster Burst-affecting work into focused blocks, or run Burst OFF for the session and re-enable to validate. Also focused: Play-validation of server==client for every netcode slice (EditMode does NOT catch a system-ordering cycle — it throws only at world creation/Play; the EB-1 cross-group production-ordering is exactly this class), and all asset/scene mutation.
**Requires a friend — and this is an EXTERNAL scheduling dependency ON the Path A critical path, not just a Path B nicety.** Three points where a second human is **non-negotiable** (co-op is a locked pillar): **Demo A** (the two-human "is two-player combat fun and readable" read), **EB-2 at Demo B** (the feed-vs-fight specialization gate — **ON Path A**), and **MC-5 at Demo C** (contested revive / focus-fire — Path B). MPPM virtual players prove replication and let the operator solo-dry-run both clients, but the *fun* sign-off for interdependence needs a second human. **Because EB-2's gate is on Path A, the spine can BLOCK on a friend's calendar, not just on code** — pre-schedule the friend session rather than discovering the dependency at the gate. Schedule friend sessions as **batched demo checkpoints**, not per-commit.
**Rhythm:** Claude runs the systems lane + instrumentation to a playable state unfocused → operator takes a **focused** session for the Burst-affecting edits, Play-validation, and the solo dry-run with live tuning → batched **friend** session for the co-op gates at the demos. Keep Burst-affecting work clustered so the unfocused/headless lane never trips the stale-binary hazard.
### Risk register
| # | Risk | Severity | Mitigation | De-risk sequence |
|---|---|---|---|---|
| R1 | **Feel can't be hit** — the dash/whiff loop reads in spec but isn't fun after tuning (the irreducible depth-first risk). | HIGH | MC-1 is *deliberately* the smallest fun slice; the fun-gate + MC-0 instrumentation force an honest verdict. If MC-1's gate fails after a real tuning pass, STOP and re-cut combat — don't build on an unfun core. | First milestone; the kill-switch for the whole project. |
| R2 | **Dash i-frame mispredict / cross-group tick mis-align** — negating by "is DashState active now" instead of against the authored tick window double-counts on rollback, AND the strike is appended a tick earlier in a DIFFERENT group than the drainer. | HIGH | Baked build-note: `DashState` carries `StartTick`; stamp non-replicated `uint SourceTick` on `DamageEvent` at all THREE sites; negate over `[StartTick, IFrameUntilTick]` via `NetworkTick`. **Review agenda item #1.** | MC-1, before `create_script`. |
| R3 | **Burst stale-binary / ICE** from the `CharacterProcessor` sharpness edit + the MC-6 per-slot recompute corrupting the cache. | MED | Focused-editor rule; expect a restart after the processor edit; cluster Burst-affecting edits; keep the per-slot recompute non-Burst if it touches cross-assembly generics/enums. | MC-1 (processor) and MC-6 (recompute). |
| R4 | **Spitter is the only new combat-track netcode** — interpolated enemy-projectile ghost; tunnelling under ~100 ms interp, plain-group `DeltaTime` trap, double-destroy; PLUS the `SpawnCounter` ring-advance must survive the mix-table refactor. | MED | Swept segment from stored `LastStep` (never `SystemAPI.Time.DeltaTime` in a plain-group system); at-most-once destroy; tunnelling + ring-determinism regression tests; speed-cap is a Play-gate; lighter design-review. | MC-2 (Path B). |
| R5 | **Structure Health is the only new EB netcode**`[GhostField]` on the interpolated `PlacedStructure` ghost re-bakes every structure prefab + touches every `SaveData`; the death-vs-production ordering spans the predicted/plain boundary; the loss BEAT is play-gated. | MED | Budget the re-bake; gate production on a live-`Health>0` read (not cross-group `[UpdateBefore]`); `SaveData` v3 persists structure HP; the loss-juice + target-preference tuning ARE the milestone. **EB-1 review-gated.** | EB-1. |
| R6 | **Downed/Dead derive-race + missing idiom clauses** — both derive from `Health<=0`; owner mispredicts downed→dead; OR "downed never triggers" because it wasn't baked-disabled / the derive didn't `.WithPresent<Downed>()`. | MED | Replicate the discriminator (`[GhostField] uint DownedUntilTick`); bake `Downed` DISABLED; derive via `.WithPresent<Downed>()`; authoritative schedule server-side. **MC-5 review-gated.** | MC-5, before coding. |
| R7 | **MC-6 serializer churn** — generalizing to a 4-slot struct + per-slot stats risks repeated ghost-hash re-bakes + a rollback-stale change-filter. | MED | Fixed `Slot0..3` struct (ONE re-bake); per-slot recompute unconditional/uncached; ship hitscan/cone first; fill 23 slots before 4. **Mandatory review.** | MC-6, last by design. |
| R8 | **Mid-siege player-count scaling trap** — live connection count changes mid-siege; per-tick re-count jitters wave size / soft-locks. | MED | SAMPLE the count ONCE at siege-arm, server-only; bounded `SiegeTimeoutTicks`. | END-5. |
| R9 | **Breadth-creep regression** — the correctness/breadth reflex slipping a new system in before the current loop is fun re-creates the hollowness; a downstream milestone "passing on paper" against an un-tuned upstream (e.g. MC-4 on a mushy dash). | MED | The depth-before-breadth gate + the [Decision Gate](#the-decision-gate-mandatory-stop-after-end-2) hard stop; "is the existing loop tuned fun yet?"; a milestone can't pass until its dependency has PASSED, not merely shipped; Phases 24 stay PAUSED. | Every milestone boundary; the Decision Gate. |
| R10 | **Friend-availability blocks the spine** — EB-2's co-op gate is on Path A and needs a second human; a friend's calendar is outside the operator's control. | MED | Surface it as a named external dependency; pre-schedule batched friend sessions (Demo A, Demo B); MPPM for the solo dry-run so the friend session is short and decisive. | Demo A, then Demo B (Path A). |
| R11 | **Solo bandwidth / coding-vs-calendar** — fun-tuning is the hidden unbounded cost; estimates are coding-time, gated by focused-editor + friend availability. | MED | The [calendar conversion](#calendar-time-the-play-budget-assumption) (~47 months for Path A at ~58 hrs/wk); instrumentation shortens the loop; defaults-first; batched friend sessions; Path B not summed. | Continuous. |
**Mandatory adversarial design review (before coding) at:** **MC-1** (dash damage path / cross-group i-frame alignment — agenda item #1), **EB-1** (the structure `[GhostField]` + the cross-group production-ordering race), **MC-2** (the new enemy-projectile ghost + the `SpawnCounter` refactor — lighter trigger), **MC-5** (downed root + revive), **MC-6** (full-slot loadout). This matches the project rule — run the netcode/relevancy · determinism/prediction · reuse/scope review BEFORE a netcode-heavy slice; it has pre-caught relevancy traps, singleton collisions, dt-traps, and double-destroys before.
### The co-op braid: the latent inventory gap stops being latent
DR-026 deliberately shipped a co-op GAP — each player's haul is PRIVATE (personal `InventorySlot` bags) with no shared-deposit affordance. **The Economy-braid track is that pass, by necessity:** a war economy that funds SHARED turrets, a SHARED base, and SHARED repairs from PRIVATE bags is incoherent with 2+ players. The discipline across EB: **every economy SPEND reads the shared `ResourceLedger` (the untagged global ghost), never personal bags** — turret feed (EB-2), repair (EB-3), crafting-to-shared-store (EB-5). Personal bags stay the gather→carry→deposit buffer; the `G`-key `InventoryDepositRequest` is the bridge from private haul to shared war economy. The shared ledger IS the co-op shared economy, and the deposit RPC is the shared-deposit affordance — which is why feed-vs-fight specialization (the most reliable co-op fun) is the reason combat-not-automation is the primary verb.
### The escalation seam (EB → Endgame handoff)
The braid creates the mechanical seam to the endgame, already half-baked into the code: **`ThreatConfig.SizePerExpeditionResource`** (baked-but-inert today — `ThreatDirectorSystem.cs:62` is `* 0 // haul-scaling deferred`) scales siege size by what you HAULED, so a richer sortie provokes a bigger siege ("the sortie feeds both," [[Identity]]). **`ThreatConfig.HeatEnabled`/`HeatPerHarvest`/`HeatThreshold`** (also inert) scale threat by how much your FACTORY hoards (the They-Are-Billions tension: success raises stakes). **EB-1's anchor-as-lose-condition fork** is the seam to END-1's real lose BEAT. **EB-5's recipe-gating-by-`GoalProgress`** is the seam to a future memory-restoration unlock spine. The endgame track activates fields the economy already feeds — it does not reinvent a difficulty curve.
### Demo checkpoints
The 23 moments you can put in front of someone and they'd call it a game, not a tech demo — the solo dev's external-validation beats and the natural batched-friend sessions.
- **Demo A — "The Duel" (after MC-1 + MC-4, Path A):** the Charger that telegraphs a committed lunge, a swarm; you watch a player learn to read the tell, dash through it, and punish the whiff. **Includes a two-human co-op read** (MPPM or friend, no new systems) — the FIRST validated-fun checkpoint includes a second human per the co-op pillar. Show the overlay's negated-hits/dash + whiff-convert readout next to the play.
- **Demo B — "The Loop" (after EB-2, Path A):** the 515 min escalating arc where turrets eat the munition the factory made (EB-2) and a siege can destroy what you built (EB-1). The demo that proves "it feels like a *game*," not just "the fight is fun." **First batched friend session** (the feed-vs-fight co-op specialization is the gate; this friend session is ON the critical path).
- **Demo C — "The Co-op Crisis" (after MC-5 + END-1, Path B):** a teammate goes down mid-fight; the bleed-out clock starts; someone pushes into the swarm to revive (exposed during the channel) while the other holds the line and breaks target to focus the healer-elite — the Core bar ticking behind them. The clearest "this is a co-op game" sell. **Second batched friend session.**
- **(Deferred) Demo D — "The Loadout" (after MC-6, Path B):** two players bring complementary 4-slot kits and feel like different classes. Depth *on top of* a game that already exists; the operator can self-validate with an MPPM second client.
## Cut / not yet (anti-breadth-creep)
Deliberately deferred so the proven path stays fast; each carries an explicit **revisit-trigger**. Note that **END-3 (the Echo narrative) and END-4 (the content treadmill) are CUT here, not scheduled** — they are pure breadth wearing a low-risk-system costume: each ships a working *trigger* in ~24 days but the actual *payoff* (memory beats, the THEM reveal, VO, new brains/abilities/biomes/boss, the unlock pacing) is unbounded operator content that an empty event bus does NOT deliver. For a content-light solo project, a placeholder-subtitle event bus is **negative value** (maintenance surface, no shipped payoff). They return only when the operator actively wants to write/author and Path A is proven fun.
| Cut item | Revisit-trigger |
|---|---|
| **END-3 — the Echo / charge-milestone memory beats** (the event-bus SYSTEM is ~2 d; the WRITING is unbounded) | Path A proven fun AND the operator actively wants to write; the THEM arbitration (Wellspring / lost crew / the Echo — three live readings in [[Identity]]) is committed or deliberately kept ambiguous BEFORE the final beat. |
| **END-4 — the content treadmill / memory-restoration unlock spine** (cheap-content recipe + unlock-at-charge table) | After MC-2's mix-table + MC-4's archetype byte land (they make new brains a `switch`-case) AND "is the existing brain tuned fun yet?" is yes; the unlock spine's gear-vs-memory-progression question is decided. |
| The **corrupted-fabricator boss** | END-4's cheap-brain spine + a real climax (END-2) exist to express it into. |
| **Per-Sleeper Echo VO** (shared subtitles first, if END-3 is ever un-cut) | END-3's shared-subtitle pipeline ships AND the operator wants per-player voice. |
| Dash **charges** + dash-as-stat (flat cooldown first) | MC-1's flat-cooldown dash passes its fun-gate AND the kit (MC-6) wants a dash-build axis. |
| **Dash-strike** / **pierce-through-bodies on dash** | MC-1 + MC-4 land clean and a playtest asks for an offensive dash (pierce risks swarm-edge mispredict — needs the collision-filter reviewed). |
| A **grabber/controller** enemy (the only brain touching predicted player state) | MC-5 infra ships AND a review validates it AND the existing brains are tuned fun (a hard-lock with no answer is pure frustration). |
| A **directional-weak-point tank** | A later roster pass after END-4's cheap-brain spine is proven. |
| **Raw-HP friendly fire** (soft-only by default) | A playtest explicitly wants the tension AND the soft-FF revive-interrupt isn't enough. |
| A **full 6+ brain roster in one pass** | After 3 brains + the elite prove the dispatch + tuning loop ("is the existing brain tuned fun yet?"). |
| The full **per-machine HP/repair-cost economy + multi-tier munition crafting trees** | EB-1's one loss beat + EB-2's one depletable resource (+ EB-3's repair if chosen) are proven fun first. |
| **Inventory/equipment Phases 24 + automation recipe/throughput breadth** | Resume only braided: EB-4 (tool-gate) after EB-2 lands; EB-5 (crafting) after MC-6. Cut any Phase 24 work that doesn't feed the fight. |
**Gate before each new brain/system: "is the existing loop tuned fun yet?" — and after END-2, the [Decision Gate](#the-decision-gate-mandatory-stop-after-end-2) must be logged before any of the above is un-cut.**
## Locked decisions (Path A)
> Locked with the operator on 2026-06-09 via a **present-the-forks** exercise — gameplay-design forks are the operator's call, never auto-decided ([[DR-029_Path_A_Fork_Locks]]). These **resolve the per-milestone "Open questions" forks for Path A**; live-singleton picks stay tunable at playtest, so a lock here is a starting default, not a cage.
| Fork | Milestone | Locked call | Reversibility |
|---|---|---|---|
| Dash facing | MC-1 | **Free aim during the dash** (strafe-dodge; matches the decoupled move/aim) | input shape |
| Dash i-frames | MC-1 | **Whole-window i-frames**; the recovery tail punishes spam | live singleton |
| Charger form | MC-1 | **New Husk variant prefab** (distinct silhouette + telegraph) | baked (prefab) |
| Cleave control | MC-4 | **Its own button** (enables dash→cleave→shoot) | input binding |
| Cleave cooldown | MC-4 | **Own cooldown**, not shared with Fire | live singleton |
| Enemy aggro | EB-1 | **Husks push for the base/structures** (attacking players in the way); you defend | live singleton |
| Structure-HP persistence | EB-1 | **Persist in SaveData v3** — a wounded base stays wounded | save schema |
| Munition types | EB-2 | **One munition ("Charge")** to start | baked (catalog) |
| Ammo scope | EB-2 | **Turret ammo only** (server-only); player abilities stay free for now | structural (player-ammo deferred) |
| Run-dry consequence | EB-2 | **Soft-fail** — turrets go quiet until fed | live singleton |
| Lose-severity | END-1 | **Soft loss** — breach drains the shared ledger / damages structures, siege ends, base persists wounded | Tuning byte |
| Breach effect | END-1 | **Core bar only**; structure destruction stays EB-1's job | structural (no double-system) |
| Core persistence | END-1 | **Persist** — a dented Core carries across save/quit (v3) | save schema |
| Charge cadence | END-2 | **Both** — surviving sieges AND Aether deposited at the Engine fill the meter | structural (two server-only writers) |
| Win-resolution | END-2 | **Keep playing** — win banner, the base is yours; NG+/endless is the later END-5 | structural (minimal) |
| Final beat | END-2 | **One big escalating wave** (boss deferred) | live singleton (size) |
| `EnemyStatus` placement | MC-5 | **Confirmed in MC-5** (interdependence theme), not a standalone MC-3 hook | — |
**Where a pick departed from the safe default:** the operator chose **charge cadence = both** (the stronger braid) over siege-survived-only. END-2 therefore reads BOTH the siege-survived increment (`CyclePhaseSystem`, ships today) AND an Aether-deposited increment at the Engine — each a **server-only single writer** (no co-op double-count), summed into `GoalProgress.Charge`. This wires the win condition straight into the economy; budget the extra deposit-charge wiring in END-2. Every other fork took the recommended default.
### The fork-locking ritual — re-run before each Path B milestone ★
Path B forks (spitter form, downed/revive shape, the multi-slot kit, crafting, scaling/NG+, and the dormant END-3/END-4 content forks) are **deliberately left open.** When the [Decision Gate](#the-decision-gate-mandatory-stop-after-end-2) selects a Path B milestone, **lock its forks first via this same exercise: present each fork to the operator with a recommendation and let them decide.** Never auto-decide a gameplay-design question, and never mark a default "official" without an explicit okay — a locked default is a starting point the operator approved, not a call Claude made for them.
## Related
- [[DR-028_Combat_Primary_Verb_Depth_First]] — the direction this roadmap executes.
- [[Pillars]] — combat as the primary braided verb + depth-before-breadth.
- [[Identity]] — the braid in fiction (Aether economy ↔ siege stakes; the Echo/THEM payoff metered by charge — deferred to the Cut table until Path A is proven).
- [[DR-017_Persistent_Base_Player_Driven_Pacing]] — the player-driven siege loop + the deferred "base-integrity / unattended-siege teeth" END-1 cashes in.
- [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-027_Equipment_Slots_Phase1]] — the paused Phases 24 EB-4/EB-5 resume in braided form.
- [[Milestones]] — the historical record; [[Backlog]] — the loose pool (inventory/automation breadth paused).
@@ -0,0 +1,59 @@
---
date: 2026-06-08
type: session
tags:
- session
- design
- direction
- combat
- roadmap
- scope
permalink: gamevault/07-sessions/2026/2026-06-08-combat-depth-direction
---
# Session 2026-06-08 — Direction: combat-first + the combat-depth track
> A design/strategy session (no code). Operator: *"This does not feel like a game... expand the design + a clear roadmap to a core playable fun loop. Pushback where it'd substantially help. Ground it in real game design + real published games + the solo-dev constraint."*
## Goal
Diagnose why an engineering-complete project doesn't feel like a game, set a direction, and lay out a combat-depth milestone track.
## Process
- **Scan:** read [[Identity]], [[Pillars]], [[Milestones]], [[Backlog]], [[Systems_Index]], DR-004, content inventory (3 abilities = same projectile; 6 enemy prefabs = 1 brain; 8 items; structures). Confirmed the gap: vast infrastructure, hollow content; "Live interactive fire test" never done.
- **Intake gate (4 questions):** operator wants **all three pillars** (the fusion is the identity / unsure), **passion/craft**, **co-op NON-NEGOTIABLE**, **solo + Claude**.
- **Diagnosis + pushback** (delivered in chat): development was breadth-first + correctness-first → a "tech demo of a game"; the four pillars are three deep genres a solo dev can't make co-equal; the fix is braid-don't-co-equal with combat as the primary verb. Grounded in The Riftbreaker / Core Keeper / Deep Rock Galactic / Risk of Rain 2 / Left 4 Dead / Vlambeer.
- **Combat-depth design pass (ultracode workflow, 9 agents):** 5 design lenses (movement/defense · ability kit · enemy AI · co-op · game feel), each grounded in real games **and** the actual DOTS code → 1 synthesis (thesis + MC-1…MC-5) → 3 adversarial critics (netcode-feasibility · solo-scope · fun), all **go-with-changes**.
## Done (decisions + docs)
- **[[DR-028_Combat_Primary_Verb_Depth_First]]** — combat is the primary braided verb; base+automation braid around it (not co-equal); **depth-before-breadth** + per-milestone **fun-gates**; inventory Phases 24 + automation breadth paused.
- **[[Path_to_Fun]]** — new north-star roadmap: the braided loop + the combat-depth track **MC-1…MC-6** (re-cut per the critics) with build notes.
- Revised [[Pillars]] (combat primary + depth-before-breadth), [[Identity]] (+the braid section), [[Milestones]] + [[Backlog]] (pivot/pause), [[00_Home/Home|Home]] (pointers).
## Key findings from the design pass (carry into MC-1)
- **The keystone is enemy COMMITMENT + a punishable whiff paired with the dodge.** The dash is the *answer*, a committed lunge is the *question*; both are inert alone (aim is already decoupled from move → kite-strafe-and-click already beats the one brain). So MC-1 ships the dash **and** the Charger lunge as one playtest unit, validated by play, not against the current commitment-free melee.
- **Two MC-1 netcode blockers (pre-caught):** (1) i-frames must negate damage per-`DamageEvent` against the tick it was authored — stamp a non-replicated `uint SourceTick` on `DamageEvent` (`HealthApplyDamageSystem` drains the prior-tick melee event in the predicted group), not "is DashState active now." (2) `CharacterControl` has no sharpness field — also drive `CharacterComponent.GroundedMovementSharpness` near-instant for the dash window, else the dash ramps ("walk faster"). Budget the Burst-processor edit (focused editor; expect a restart).
- **Telegraph readability is a PRECONDITION, not polish:** drive the cue off the **replicated `AttackWindup` tick countdown** (enemies are interpolated ~100ms late); lengthen windups to ~27+ ticks; make enemy-projectile dodgeability a Play-gate.
- **Co-op validated early + cheap:** pull a server-only `EnemyStatus` synergy byte into MC-3 (slow+burst duo > two soloists). Downed/revive (MC-5) replicates a `[GhostField] uint DownedUntilTick` discriminator to keep the derive rollback-correct; no server-only `KnockbackState` on predicted players.
- **MC-6 (multi-slot kit) is last + review-gated** — the only HIGH-risk item; ship hitscan/cone (no predicted spawn) before generalizing `ProjectileClassificationSystem` to a ghost-type set; 23 slots before 4.
## Addendum — roadmap refinement (same day, ultracode)
Operator: *"Lets refine the roadmap fully."* A second multi-agent pass refined [[Path_to_Fun]] end-to-end — 4 deepen lenses (combat track · the economy braid · endgame/win-lose · production discipline, each grounded in the actual code) → synthesis → 3 code-grounded adversarial critics (netcode-feasibility · solo-scope · fun-coherence), all **go-with-changes**. Outcome:
- **Path A / Path B hard split + a mandatory Decision Gate.** Committed **Path A** = MC-0 (instrument the box) · MC-1 (dash + committed Charger) · MC-4 (melee cone) · EB-1 (machines can die) · EB-2 (felt spend / turret ammo from the factory) · END-1 (a losable Core) · END-2 (final siege, win/lose) — *a complete, shippable small game with a point.* Everything else (MC-2/3/5/6 · EB-3/4/5 · END-5) is **provisional Path B**, re-estimated only after Path A's fun-gates pass; **END-3 narrative + END-4 content-treadmill CUT** to the revisit table (an empty event bus is negative value for a content-light solo project). No Path B work begins until an explicit ship-vs-continue decision is logged.
- **Code-grounded corrections (critics read the systems):** a THIRD `DamageEvent` stamp site (`TurretFireSystem.cs:95`); `DashState` needs an explicit `StartTick`; the i-frame negation is a cross-group tick-alignment problem (`HealthApplyDamageSystem` in the predicted group drains the strike `EnemyAISystem` appended a tick earlier in the plain group) — MC-1 mandatory-review agenda item #1. The MC-2 "wave director" is already built (Threat/Cycle/Wave) — only the weighted enemy-MIX table is new.
- **New cross-cutting discipline:** a falsifiable fun-gate protocol + MC-0 instrumentation (so feel claims are counted — e.g. timed-vs-spam dash hit-counts), a ~35-row tuning-knob surface (baked vs live server-singleton + defaults), the solo+Claude two-lane cadence (friend = an EXTERNAL Path-A dependency at EB-2's Demo B), a risk register (R1R11), and demo checkpoints (Duel / Loop / Crisis). 10 operator forks catalogued (locked the next day — see Addendum 2).
Direction unchanged ([[DR-028_Combat_Primary_Verb_Depth_First]]); the refinement sharpened scope realism, falsifiability, and netcode precision.
## Addendum 2 — Path A forks locked (2026-06-09)
Operator process correction: *gameplay-design forks are the operator's call — present each with a recommendation, never auto-decide or mark a default "official" without an okay* (a workflow attempting to auto-lock every fork was halted mid-run). Worked the forks interactively; **Path A is now fully locked** ([[DR-029_Path_A_Fork_Locks]] · [[Path_to_Fun#Locked decisions (Path A)]]): free-aim + whole-window dash · Charger = a new prefab · cleave = its own button + cooldown · Husks push-for-base · soft-loss + wounded-base-persists (SaveData v3) · turret-only ammo with soft run-dry · win-meter = **both** sieges + Aether deposits (the one non-default pick — the stronger braid) · winning = keep-playing · final = one big escalating wave. Live-singleton picks stay tunable at playtest. **Standing rule:** re-run the same present-the-forks ritual before each Path B milestone; Path B forks stay open. Saw also [[present-forks-dont-auto-decide]].
## Next
Path A is locked and concretely specified. Operator's call: **start MC-0 + MC-1** (instrument the box, then dash + Charger committed-lunge + readable telegraph — honoring the three-site `SourceTick` i-frame fix, the `DashState.StartTick` window, and the Burst-affecting `CharacterProcessor` edit) via a normal plan→approve→build slice — or commit the refined vault docs. Per [[Path_to_Fun]], **MC-1 is the project kill-switch**: if its fun-gate fails after a real tuning pass, STOP and re-cut combat before building on it.
@@ -0,0 +1,249 @@
---
date: 2026-06-09
type: session
tags:
- session
- combat
- mc-1
- build-spec
- netcode
permalink: gamevault/07-sessions/2026/2026-06-09-mc1-build-spec
---
# MC-1 Build Spec (from the mandatory pre-code design review, 2026-06-09)
> The code-grounded implementation spec for MC-1 (dash + Charger duel). Produced by the mandatory adversarial design review (netcode/determinism · reuse/scope · feel-feasibility, all go-with-changes). Drives the build. Roadmap: [[Path_to_Fun]] (MC-1); locks: [[DR-029_Path_A_Fork_Locks]].
# VERDICT: go-with-changes
## IMPLEMENTATION SPEC
# MC-1 Implementation Spec (verified against code; ground-truth confirmed)
All three review lenses verified the ground-truth against the actual files. Verdict **go-with-changes**: the architecture is sound and idiom-correct; the changes are precision pins (half-open negation window, per-site SourceTick clock + NonZero, idempotent DashSystem split, component-presence Charger discriminator). One material correction confirmed in code below.
## CONFIRMED CORRECTION — the dash "blink" needs NO CharacterProcessor edit (drops R3 from MC-1)
`CharacterProcessor.HandleVelocityControl` (CharacterProcessor.cs:105-121) reads `characterComponent.GroundedMovementSharpness` **as a RW ref** and lerps `characterBody.RelativeVelocity` toward `CharacterControl.MoveVelocity` via `StandardGroundMove_Interpolated`. `CharacterComponent` is plain RW `IComponentData`. So a predicted `DashSystem` raising `GroundedMovementSharpness` to ~200 for the dash window (and restoring 15 after) produces the snap with **zero edit to the Bursted processor** — fully headless, no focused-editor Burst restart. The Tuning-knob surface itself names this as the fix ("Dash movement sharpness ~200, the GroundedMovementSharpness override fix"). **DEFAULT = sharpness-override.** The direct `RelativeVelocity` write inside the processor is kept ONLY as a documented fallback if Play shows the lerp-to-200 still visibly ramps — and only THAT fallback carries the Burst-restart. This means MC-1's only focused-editor step is the `.inputactions` wrapper regen + Play-validation.
---
## 1. Components
### EDIT `DamageEvent` (Simulation/Combat/DamageEvent.cs) — currently exactly `{float Amount; int SourceNetworkId}`, IBufferElementData, server-side, NOT replicated
Add: `public uint SourceTick;` — the raw ServerTick at which the hit LOGICALLY LANDS (the appending tick). **Confirmed zero ghost-hash impact** (the buffer is non-replicated; its doc-comment says so). Update the doc-comment to note SourceTick = the tick the strike was authored, used by the dash i-frame negation.
### EDIT `PlayerInput` (Simulation/Player/PlayerInput.cs)
Add after `Fire`: `[GhostField] public InputEvent Dash;` (verbatim Fire twin). In `ToFixedString()` append `s.Append(';'); s.Append(Dash.Count);`. **Churn class: command-hash change** (InputBufferData<PlayerInput> serializer + command-collection hash) — both peers rebuild; NOT a ghost-prefab re-bake.
### NEW `DashState` (Simulation/Player/DashState.cs) — predicted, NON-replicated, derived each tick (clone KnockbackState SHAPE only)
```
public struct DashState : IComponentData {
public float2 Dir; // planar XZ dash heading, captured at dash-start
public uint StartTick; // raw ServerTick at dash-start (NonZero-coerced)
public uint IFrameUntilTick;// StartTick + iFrameWindow (NonZero); i-frames active while this .IsNewerThan(SourceTick)
public uint RecoverUntilTick;// IFrameUntilTick + recoverTail (NonZero); movement-lock tail, no i-frames
}
```
NOT a `[GhostField]` (so no player-ghost re-bake). The SERVER re-derives it every predicted tick from the replicated `Dash` InputEvent, so it is authoritative at drain time. Bake DISABLED-equivalent (all-zero) via PlayerCharacterAuthoring AddComponent. Doc-comment must state: SHAPE-clone of KnockbackState, but UNLIKE KnockbackState it lives on a PREDICTED player and is re-simulated from input (not server-only-mutated on an interpolated ghost).
### NEW `DashCooldown` (Simulation/Player/DashCooldown.cs) — AbilityCooldown twin
```
public struct DashCooldown : IComponentData { [GhostField] public uint NextTick; } // 0 = ready
```
`[GhostField]` so the owning client doesn't mispredict cooldown across rollback/reconnect — exactly AbilityCooldown.cs:28. Bake `{NextTick=0}`.
### NEW `LungeState` (Simulation/Combat/LungeState.cs) — server-only, KnockbackState twin, baked ONLY on the Charger prefab
```
public struct LungeState : IComponentData {
public float2 Dir; // fixed lunge heading, locked at commit
public float Speed; // lunge speed (units/s); 0 = not lunging
public uint UntilTick; // raw tick the lunge ends (NonZero); active while .IsNewerThan(serverTick)
}
```
NOT a `[GhostField]` (lunged position replicates via stock LocalTransform, like KnockbackState). **Component-presence IS the Charger discriminator** — no enum/brain byte (honors the Burst cross-assembly-enum rule; EnemyAISystem is `[BurstCompile]`).
### EDIT `EnemyStats` (Simulation/Combat/EnemyComponents.cs) — add per-prefab windup so the Charger telegraph does NOT globally slow every Husk
Add `public int WindupTicks;` Grunt bakes ~18 (current global), Charger bakes ~30. EnemyAISystem reads `stats.WindupTicks` instead of the global `Tuning.AttackWindupTicks` at the windup-set site (EnemyAISystem.cs:167). (The knob surface promotes `Tuning.AttackWindupTicks`→28 globally — REJECT that for the Charger; per-prefab is the right first cut. Keep a TuningConfig singleton override only if live-tuning the Charger lead is wanted; baked-per-prefab is the MC-1 default.)
### MC-0 prerequisite — `DevTelemetry` ALREADY EXISTS (Simulation/Debug/DevTelemetry.cs)
Verified present as a server-only `IComponentData` singleton with the exact counters: `DashIFrameNegatedHits`, `DashesWasted`, `ChargerWhiffWindowsOpened`, `ChargerWhiffPunishesLanded`. NO blocker on telemetry home. (The MC-0 TuningConfig live-singleton for feel knobs is referenced as "building" — see Blockers: confirm it lands before wiring DashSystem to read LIVE values; until then use the baked defaults below.)
---
## 2. SourceTick stamp — all THREE sites, each with its OWN appending-tick clock, all via TickUtil.NonZero
The negation compares `DamageEvent.SourceTick` against the player's stored DashState window. The stamp MUST be the tick the strike LANDS (the appending tick), NOT the drain tick — because EnemyAISystem/TurretFireSystem append in the PLAIN group (drained tick N+1), while ProjectileDamageSystem appends in the predicted group (drained same tick).
1. **EnemyAISystem.cs:144** (melee strike) — add `SourceTick = TickUtil.NonZero(now)` (`now` = the local var already computed at line 63 = `serverTick.TickIndexForValidTick`).
2. **ProjectileDamageSystem.cs:129** — add `SourceTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick)` (`nt` is fetched at line 64, guarded by `haveTick`; if `!haveTick` stamp 0 — treated as no-i-frame).
3. **TurretFireSystem.cs:95** — add `SourceTick = TickUtil.NonZero(now)` (`now` = line 40 = `serverTick.TickIndexForValidTick`).
4. The **MC-4 cleave** and **EB-1/EB-2** future DamageEvent appends must also stamp SourceTick (already called out in the spec).
NonZero at every site mirrors how StartTick/IFrameUntilTick are coerced, so a stamped event is never 0 and never collides with the "0 = ready" sentinel.
---
## 3. The i-frame negation in HealthApplyDamageSystem — HALF-OPEN, PER-ELEMENT
Add in HealthApplyDamageSystem.OnUpdate, inside the per-entity foreach, AFTER the RespawnInvuln gate (lines 52-64) and at the sum loop (lines 66-69). It must be **per-element** (skip only the in-window DamageEvent), NOT a whole-buffer clear like RespawnInvuln/GodMode — a same-tick turret/projectile event on the player must still apply (defensive correctness; in practice only enemy melee targets the player today, but per-element is the documented-correct shape).
```
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
float total = 0f;
for (int i = 0; i < dmg.Length; i++) {
uint src = dmg[i].SourceTick;
if (hasDash && src != 0 && ds.IFrameUntilTick != 0) {
var srcTick = new NetworkTick(src);
var startTick = new NetworkTick(ds.StartTick);
var untilTick = new NetworkTick(ds.IFrameUntilTick);
// i-framed iff StartTick <= src AND src < IFrameUntilTick (half-open, matches RespawnInvuln/cooldown convention)
bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick); // src >= start
bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick); // src < until
if (atOrAfterStart && beforeUntil) {
telemetry.DashIFrameNegatedHits++; // MC-0 increment (per negated event)
continue; // negate this event
}
}
total += dmg[i].Amount;
}
dmg.Clear();
```
**Convention pin (BLOCKER 1):** the window is `[StartTick, IFrameUntilTick)` — lower bound INCLUSIVE, upper bound EXCLUSIVE — matching RespawnInvuln/AbilityCooldown/EnemyAttackCooldown where `until.IsNewerThan(now)` is false at `now==until` (i.e. the gate OPENS at tick==until). Store `IFrameUntilTick = StartTick + iFrameWindowTicks`; tick==IFrameUntilTick is NO LONGER i-framed. `src==0` (unstamped) is treated as NEVER i-framed (damage applies — fail-safe). Use NetworkTick comparisons ONLY (never raw uint), so tick-wraparound is correct.
`telemetry` = `SystemAPI.GetSingletonRW<DevTelemetry>()` fetched once at top of OnUpdate (guard with TryGetSingleton; if absent, skip increments — keeps EditMode worlds without a telemetry singleton green).
---
## 4. Predicted DashSystem (Simulation/Player/DashSystem.cs)
Attributes: `[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]` `[UpdateAfter(typeof(PlayerControlSystem))]` `[BurstCompile]` (plain ISystem; no managed). Filter `.WithAll<Simulate>().WithDisabled<Dead>()`. Verify in Play it sorts BEFORE `PredictedFixedStepSimulationSystemGroup` (PlayerControlSystem already establishes that precedent — match it; the override must land before the processor lerps that tick).
Query: `RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>, RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>` + `.WithEntityAccess()` (need InputBufferData if reading absolute Dash count — optional, see below).
Per-tick logic (the START is an idempotent pure function of replicated input+tick → NO IsFirstTimeFullyPredictingTick guard; the OVERRIDE must run EVERY predicted pass so rollback re-applies it):
```
uint now = serverTick.TickIndexForValidTick; // serverTick = GetSingleton<NetworkTime>().ServerTick
// --- one-off START (idempotent): fresh press edge + cooldown ready + not already mid-dash ---
bool ready = cd.NextTick == 0 || !new NetworkTick(cd.NextTick).IsNewerThan(serverTick);
bool windowActive = ds.RecoverUntilTick != 0 && new NetworkTick(ds.RecoverUntilTick).IsNewerThan(serverTick);
if (input.Dash.IsSet && ready && !windowActive) {
float2 dir = facing.Direction; // FREE AIM: dash heading = current facing (locked decision: free aim during dash)
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0,1);
dir = math.normalize(dir);
ds.Dir = dir;
ds.StartTick = TickUtil.NonZero(now);
ds.IFrameUntilTick = TickUtil.NonZero(now + iFrameWindowTicks); // default 12
ds.RecoverUntilTick= TickUtil.NonZero(now + iFrameWindowTicks + recoverTailTicks); // +9
cd.NextTick = TickUtil.NonZero(now + dashCooldownTicks); // default 45
}
// --- per-pass OVERRIDE while i-frame window active (idempotent; runs on every rollback re-sim) ---
bool iFrameActive = ds.IFrameUntilTick != 0 && new NetworkTick(ds.IFrameUntilTick).IsNewerThan(serverTick);
if (iFrameActive) {
control.MoveVelocity = new float3(ds.Dir.x, 0f, ds.Dir.y) * dashSpeed; // dashSpeed = dashDistance / iFrameWindowSeconds
characterComponent.GroundedMovementSharpness = dashSharpness; // ~200 -> blink
} else if (windowActive_recover) {
// recovery tail: i-frames OFF, movement still locked-low so a panic-dash is punishable (no input control)
control.MoveVelocity = float3.zero;
characterComponent.GroundedMovementSharpness = 15f; // restore so the stop is crisp
} else {
// window fully elapsed: restore sharpness if we ever changed it
characterComponent.GroundedMovementSharpness = 15f;
}
```
- Does NOT write rotation — free-aim/strafe-dodge is automatic (PlayerAimSystem owns facing, not gated by dash). Confirmed PlayerAimSystem.cs:24-27.
- `dashSpeed` derived from the distance knob so distance is the tuned value: `dashSpeed = dashDistance / (iFrameWindowTicks / 60f)`.
- **Death/cleanup (major):** DashSystem is `.WithDisabled<Dead>()` so it won't visit a dead player → a player who dies mid-dash leaves a stale future window + sharpness=200, which would grant spurious i-frames + a stuck-fast respawn. FIX: in PlayerDeathStateSystem (which already visits `.WithPresent<Dead>()` and zeroes MoveVelocity, lines 30-39), ALSO when `isDead`: zero DashState (StartTick/IFrameUntilTick/RecoverUntilTick = 0) and restore `GroundedMovementSharpness = 15`. Add DashState + CharacterComponent to that query.
---
## 5. Charger LungeState branch in EnemyAISystem (server-only, sole position writer)
This is the largest hidden cost — a REWRITE of the strike branch for the Charger, not an additive field. Discriminate by `LungeState` component presence via a SECOND query pass (`.WithAll<EnemyTag, LungeState>()`) OR an optional `ComponentLookup<LungeState>` on the existing query — a second pass is cleaner and keeps the Grunt path byte-free. Recommend: keep the existing Grunt foreach unchanged; add a Charger-specific foreach that ALSO matches `LungeState`, and EXCLUDE Chargers from the Grunt pass via `.WithNone<LungeState>()` so a Charger is driven only by the Charger branch.
**Explicit state precedence (in one place):** knockback > lunge-active > lunge-commit(windup-elapse) > seek/strike. Each `continue`s after writing Position (preserve the SOLE-writer invariant — NO separate LungeSystem).
Charger per-enemy logic:
1. **Knockback wins** (a shot staggers the charge): reuse the existing knockback branch (lines 80-94); ADD `lunge.ValueRW.UntilTick = 0` there so a knocked-back Charger's lunge is cancelled (otherwise two position writers contend). Keep `windup=0`.
2. **Lunge active** (`LungeState.UntilTick` newer than serverTick): move fixed `lunge.Dir` at `lunge.Speed*dt` via `SweptMove`; hold Y. Deal contact damage DURING travel: if `EnemyAIMath.InAttackRange(pos, nearestPlayerPos, AttackRange)` append the `DamageEvent{Amount, SourceNetworkId=-1, SourceTick=NonZero(now)}` (at-most-once via cooldown). **Whiff detection:** compare intended displacement vs SweptMove-actual; if `actualTravel < intendedTravel * whiffFraction` (wall-stop) OR the lunge timer elapses without ever entering AttackRange (overshoot) → enter stagger: `cooldown.NextAttackTick = TickUtil.NonZero(now + whiffStaggerTicks)` (default 36) and clear `lunge.UntilTick`; `telemetry.ChargerWhiffWindowsOpened++`. `continue`.
- To get the wall-hit signal cleanly: have `SweptMove` return the travel fraction (it already has `out var hit` internally), or recompute `|result-pos| / |intended-pos|` at the call site.
3. **Lunge commit** (windup-elapse, replaces the instant strike at lines 144-152 FOR THE CHARGER ONLY): do NOT append the instant DamageEvent. Instead capture `lunge.Dir = normalize(targetPos - pos)` ONCE, set `lunge.Speed = chargerLungeSpeed` (default 16), `lunge.UntilTick = TickUtil.NonZero(now + lungeDurationTicks)`, clear `windup`. The lunge travels next tick.
4. **CRITICAL — cancel-on-leave-range must NOT apply to the Charger commit.** EnemyAISystem.cs:135-137 cancels the windup if the target leaves AttackRange. For the Charger, the whole point is the commit FIRES even when the player has dodged out of range. So the Charger windup must NOT use the cancel path — once the long windup elapses, it locks dir and lunges regardless of current range. (Gate the cancel to the Grunt pass only; the Charger pass has no leave-range cancel.)
5. Charger uses `stats.WindupTicks` (~30) for the telegraph, set at the windup-arm site.
The Charger's commit telegraphs via the EXISTING `[GhostField] AttackWindup.WindUpUntilTick` — CombatFeedbackSystem already edge-detects it (CombatFeedbackSystem.cs:127,132-137). The longer lead is the readable tell.
**Whiff-punish telemetry:** in HealthApplyDamageSystem, when a player-sourced DamageEvent (`SourceNetworkId >= 0`) lands on an entity that has a Charger `LungeState` AND its `EnemyAttackCooldown.NextAttackTick` is in the whiff-stagger window → `telemetry.ChargerWhiffPunishesLanded++`.
---
## 6. Charger prefab variant (new ghost, additive — no spawn-code change)
- NEW `ChargerAuthoring : MonoBehaviour` (duplicate EnemyAuthoring) that bakes the same Husk components PLUS `AddComponent<LungeState>(entity)` (zeroed) and bakes Charger EnemyStats (longer WindupTicks ~30, lunge-appropriate MoveSpeed). Keep the GhostAuthoring (interpolated, ownerless) inherited by duplicating an existing interpolated ghost prefab (Husk.prefab or UpgradePickup.prefab) per the new-ghost recipe.
- Add the Charger prefab to the `WaveEnemyPrefab` buffer pool — WaveSystem.cs:83-92 spawns it round-robin via `baked.WithPosition` unchanged. **Churn class: new ghost prefab, additive — no re-hash of Grunt/Swarmer/Brute.**
---
## 7. Input wiring (.inputactions + wrapper regen)
- VERIFIED: `Assets/Settings/Project M Input.inputactions` has only Move/Aim/Fire. Fire binds `<Mouse>/leftButton`, `<Gamepad>/rightTrigger`, AND `<Keyboard>/space` (lines 195-213). The wrapper `ProjectMInput.cs` is correctly routed into ProjectM.Client via `wrapperCodePath` in the .meta (gotcha already honored).
- Add a `Dash` Button action. **Do NOT bind to Space** — Space is BOTH already a Fire binding (would double-fire) AND in the kbm-active sentinel (PlayerInputGatherSystem.cs:105). Bind Dash to `<Keyboard>/leftShift` + `<Gamepad>/buttonEast` (or another free pad button — not rightTrigger which is Fire).
- Regenerate the wrapper via the importer (re-import the .inputactions on a FOCUSED editor — generated GUID-referenced code, do NOT hand-edit). wrapperCodePath stays `Assets/_Project/Scripts/Client/Input/ProjectMInput.cs`.
- In PlayerInputGatherSystem (mirror Fire exactly, lines 160-162): add `bool dashPressed = gameplay.Dash.WasPressedThisFrame() && !BuildPaletteState.Active;` then in the per-player loop `input.ValueRW.Dash = default; if (dashPressed) input.ValueRW.Dash.Set();`. **Fold the keyboard dash key into `kbmActive`** (add `keyboard.leftShiftKey.isPressed` to line 100-106) and the gamepad dash button into `gamepadActive` (add `gamepad.buttonEast.isPressed` — already partially there at line 90) so a dash-only actuation flips the active scheme correctly.
- **Churn: command-hash change** (new InputEvent on PlayerInput) — both peers rebuild; the wire type is unconditional (no `#if`) so the handshake matches; no prefab re-bake.
---
## 8. Dash juice (must be in MC-1)
Client-only, in CombatFeedbackSystem (PresentationSystemGroup, observe-only). Edge-detect the local `DashCooldown.NextTick` advance exactly as it edge-detects `AbilityCooldown.NextFireTick` for the muzzle flash (CombatFeedbackSystem.cs:189-202): on the 0→nonzero / advance edge fire afterimage/whoosh + a directional camera nudge (camera punch, NEVER Time.timeScale) + an i-frame shimmer. The client can derive its own DashState identically. **Suppress player hit-feedback while the local i-frame window is active** (the client derives DashState too) so the prediction-reconciliation Health flicker (client predicts hit → server negates → snapshot corrects) does NOT read as a phantom "I got hit" flash on a clean dodge — this is the documented acceptable-not-a-bug interaction.
## 9. Telemetry increment sites (MC-0 DevTelemetry, already exists)
- `DashIFrameNegatedHits++` — per negated event in HealthApplyDamageSystem (§3).
- `DashesWasted++` — in DashSystem, lazily WHEN the dash window CLOSES having negated 0 hits (define "wasted" = the i-frame window overlapped no incoming Charger strike SourceTick), NOT at dash-start (so it counts genuine mistimes, not early presses). Simplest: track a per-player negated-count on DashState and on window-close, if 0 negations occurred this dash, increment.
- `ChargerWhiffWindowsOpened++` — EnemyAISystem when a lunge enters stagger (§5.2).
- `ChargerWhiffPunishesLanded++` — HealthApplyDamageSystem when a player-sourced hit lands on a staggered Charger (§5).
## Knob defaults (baked until the MC-0 TuningConfig live-singleton lands; then promote the feel-critical ones)
Dash distance 4.0 · i-frame window 12 ticks · recovery tail 9 ticks · cooldown 45 ticks · sharpness ~200 (baked const) · Charger telegraph lead 30 ticks · Charger lunge speed 16 · whiff-stagger 36 ticks · Charger windup (per-prefab EnemyStats) ~30. Route every stored tick through TickUtil.NonZero; compare with NetworkTick.IsNewerThan only.
## EDITMODE TESTS
1. NEGATION BOUNDARY (half-open window) — seed NetworkTime.ServerTick = drainTick (e.g. T+1) via the TelegraphTests SetServerTick pattern; put DashState{StartTick=S, IFrameUntilTick=S+W} on the entity; append DamageEvents with SourceTick in {S-1, S, S+1, S+W-1, S+W, S+W+1}; tick HealthApplyDamageSystem once; assert Health UNCHANGED for src in [S, S+W) (i.e. S, S+1, S+W-1 negated) and REDUCED for src==S-1, src==S+W, src==S+W+1. Pins the exact first-negated (S) and first-NOT-negated (S+W) ticks so the boundary is frozen.
2. TICK-WRAPAROUND negation — DashState.StartTick near uint.MaxValue, IFrameUntilTick wrapping past 0; DamageEvent.SourceTick straddling the wrap; assert NetworkTick.IsNewerThan negates correctly (proves no raw-uint compare leaked in).
3. SourceTick==0 fail-safe — append a DamageEvent with SourceTick=0 while a DashState window is active; assert damage APPLIES (unstamped events are never i-framed).
4. PER-ELEMENT (not whole-buffer) negation — same tick, append one in-window melee DamageEvent (SourceNetworkId=-1) AND one out-of-window event; assert ONLY the in-window event is negated, the other still subtracts (proves the dash gate is per-element, unlike the whole-buffer RespawnInvuln/GodMode clears).
5. RespawnInvuln still whole-buffer — regression: a DashState entity ALSO under RespawnInvuln negates ALL damage (RespawnInvuln path unchanged, runs before the per-element dash loop).
6. DASH START IDEMPOTENCE (rollback determinism) — register DashSystem in a bare predicted-style group; set Dash.IsSet + DashCooldown ready; run the start tick TWICE at the same ServerTick; assert identical StartTick/IFrameUntilTick/RecoverUntilTick/DashCooldown.NextTick (the start is a pure function of input+tick; no double-trigger).
7. DASH COOLDOWN GATE — dash, advance ServerTick past IFrameUntilTick but before DashCooldown.NextTick, set Dash.IsSet again; assert NO new dash (window unchanged); advance past cooldown, set again; assert a new dash starts.
8. DEATH MID-DASH CLEANUP — dash (window in the future), set Health<=0, tick PlayerDeathStateSystem; assert DashState window zeroed AND GroundedMovementSharpness restored to 15 (no spurious respawn invuln, no stuck-fast).
9. DASH VELOCITY OVERRIDE ORDERING — with DashState i-frame active, tick PlayerControlSystem then DashSystem; assert CharacterControl.MoveVelocity == Dir*dashSpeed (DashSystem overrode PlayerControl's input velocity) and GroundedMovementSharpness == dashSharpness.
10. STAMP NON-ZERO — drive each of the three append sites (or unit-test the stamp expression) and assert the produced SourceTick is never 0 (NonZero coercion at all three).
11. CHARGER COMMIT FIRES OUT OF RANGE — Charger windup elapses while the target has LEFT AttackRange; assert it does NOT cancel (LungeState entered, Dir locked to target-at-commit) — contrasts with the Grunt cancel-on-leave-range path.
12. CHARGER WHIFF -> STAGGER — give a Charger a fixed lunge Dir and a target offset so SweptMove stops short / never enters range; tick the lunge to expiry; assert EnemyAttackCooldown.NextAttackTick is extended by whiffStaggerTicks, LungeState.UntilTick cleared, and ChargerWhiffWindowsOpened incremented.
13. CHARGER KNOCKBACK CANCELS LUNGE — a Charger mid-lunge receives KnockbackState; tick EnemyAISystem; assert the knockback branch wins (position written by knockback) AND LungeState.UntilTick is cleared (no two-writer contention).
14. CROSS-GROUP i-FRAME TICK-COVERAGE REGRESSION (the required tunnelling-style test, EXPRESSED AS A TICK-COVERAGE TABLE, with an explicit PLAY-validation note) — the plain EditMode harness CANNOT reproduce the predicted-vs-plain group split (systems register unsorted, one tick, no group separation), so this test does NOT tick EnemyAISystem then HealthApplyDamageSystem to recreate N->N+1. Instead it asserts the NEGATION COVERS the cross-group offset directly: seed drainTick = T+1, DashState window [T-2, T+3], append DamageEvents with SourceTick = {T-3, T-2, T (a melee strike authored at T, drained at T+1), T+2, T+3, T+4}; assert exactly the in-window ones (T-2..T+2) negate and T-3/T+3/T+4 apply — proving a strike authored at T (and drained a tick later) is still negated by the window that covered T. Include an assertion-comment that the actual N->N+1 group timing (EnemyAISystem appends at T, drains at T+1) is a PLAY-VALIDATION item (review agenda item #1: server's DashState window at drain time covers the melee strike appended a tick earlier in the plain group), NOT EditMode-reproducible.
## BUILD ORDER
1. 1. (CLAUDE) DamageEvent.SourceTick field + stamp all THREE sites (EnemyAISystem:144, ProjectileDamageSystem:129, TurretFireSystem:95) via TickUtil.NonZero. Headless, no behavior change yet. Edit Assets .cs via MCP apply_text_edits/create_script, read_console after.
2. 2. (CLAUDE) DashState + DashCooldown components; the HealthApplyDamageSystem per-element half-open negation branch + DevTelemetry.DashIFrameNegatedHits increment (DevTelemetry already exists). Write the negation-boundary + wraparound + per-element + SourceTick==0 EditMode tests FIRST/alongside (fully headless-coverable via the TelegraphTests SetServerTick pattern). This is the kill-switch foundation — Play-validate the cross-group alignment (agenda item #1) before building feel on top.
3. 3. (CLAUDE) Dash InputEvent on PlayerInput + ToFixedString; PlayerInputGatherSystem reset+Set + fold into kbmActive/gamepadActive. Edit the .inputactions JSON (Write is fine — non-asset). THEN: (OPERATOR-FOCUSED-EDITOR) re-import the .inputactions to regen ProjectMInput.cs on a FOCUSED editor (generated GUID code); command-hash change means BOTH peers rebuild (no MPPM half-update).
4. 4. (CLAUDE) DashSystem [UpdateAfter(PlayerControlSystem)] with the sharpness-override blink (DEFAULT path — NO CharacterProcessor edit, headless). PlayerDeathStateSystem cleanup (clear DashState + restore sharpness on death). Dash-determinism + cooldown + death-cleanup + override-ordering EditMode tests. (OPERATOR-FOCUSED-EDITOR) Play-validate the SNAP TEST (RelativeVelocity reaches dash speed in 1-2 ticks) + server==client DashState window via execute_code; confirm DashSystem sorts before PredictedFixedStepSimulationSystemGroup. Verify CharacterControlUtilities.StandardGroundMove_Interpolated lerp form via unity_reflect before committing sharpness ~200.
5. 5. (OPERATOR-FOCUSED-EDITOR, FALLBACK ONLY) IF Play shows the lerp-to-200 still visibly ramps: the direct characterBody.RelativeVelocity write inside CharacterProcessor.HandleVelocityControl — THIS is the Burst-affecting edit (Burst-off for the session, expect a restart, Play-validate). Skip entirely if the sharpness path passes the SNAP TEST (expected).
6. 6. (CLAUDE) LungeState component (server-only); EnemyStats.WindupTicks per-prefab field; the Charger branch rewrite in EnemyAISystem (knockback>lunge>commit>seek precedence, SweptMove whiff detection, no cancel-on-leave-range for the Charger) + ChargerWhiffWindowsOpened/PunishesLanded increments. Charger commit/whiff/knockback-cancel EditMode tests. read_console for the Burst cross-assembly-enum/generic hazards (component-presence discriminator keeps it byte-free).
7. 7. (OPERATOR-FOCUSED-EDITOR for prefab authoring) ChargerAuthoring (duplicate EnemyAuthoring + AddComponent<LungeState>); duplicate an interpolated ghost prefab for the GhostAuthoring; add the Charger to the WaveEnemyPrefab pool via manage_scene/manage_prefabs; verify baked components via execute_code. Additive ghost — no Grunt re-hash.
8. 8. (CLAUDE) Dash juice in CombatFeedbackSystem (edge-detect DashCooldown; afterimage/whoosh/camera-nudge/shimmer; suppress player hit-feedback during the local i-frame window). DashesWasted increment (lazy, on window-close with 0 negations).
9. 9. (OPERATOR) MANDATORY netcode design review BEFORE create_script of the netcode-heavy slices (agenda item #1 = cross-group i-frame alignment), then the bench (timed vs spam, >=70% fewer hits) + the friend-read at Demo A. ALL feel tuning. This is the kill-switch gate.
## BLOCKERS
- MC-0 TuningConfig LIVE-SINGLETON for feel knobs is NOT confirmed in code. DevTelemetry (the counters) EXISTS at Simulation/Debug/DevTelemetry.cs — good — but I found no TuningConfig/feel-knob singleton (Tuning.cs is still compile-time consts only). The dash/Charger LIVE-tuning loop the fun-gate depends on requires that singleton. NOT a hard blocker for building MC-1 with BAKED defaults, but the operator must confirm MC-0's SetTuning singleton lands BEFORE the tuning pass, or DashSystem/EnemyAISystem must read baked consts first and be re-pointed at the singleton later. Either decide to land the TuningConfig singleton in MC-0 now, or accept baked-const defaults for the first Play pass.
- The mandatory netcode design review (agenda item #1: at the drain tick, does the server's DashState i-frame window compared via SourceTick correctly cover a melee strike appended a tick earlier in the plain group) MUST run BEFORE create_script of the negation + DashSystem + Charger slices, per CLAUDE.md and the operator's standing rule. This is a process gate, not a code defect — flagged so it is not skipped under time pressure.
## OPEN CONCERNS FOR OPERATOR
- DASH-FEEL FORK RESOLVED IN CODE: the 'CharacterProcessor edit is the only focused-editor piece' framing in the spec is FALSE for the DEFAULT path. CharacterProcessor.cs:117 reads GroundedMovementSharpness as a RW ref, so a predicted DashSystem raising it to ~200 produces the blink with NO processor edit and NO Burst restart (R3 drops out of MC-1). The Tuning-knob surface itself names sharpness ~200 as the fix. Recommend trying the sharpness path FIRST; keep the direct RelativeVelocity processor write as a documented fallback that carries the Burst-restart ONLY if Play shows a visible ramp. This likely makes MC-1's movement slice fully headless except the .inputactions wrapper regen.
- PER-PREFAB WINDUP vs GLOBAL PROMOTION: the knob surface proposes promoting Tuning.AttackWindupTicks to ONE singleton at 28, which would slow EVERY Husk's strike (changing the existing fight feel), not just the Charger. Recommend a per-prefab EnemyStats.WindupTicks (Grunt ~18, Charger ~30) instead; promote to a live singleton later if Charger-lead live-tuning is wanted. Confirm this is acceptable (it diverges from the literal knob-surface row).
- RE-DASH AT T+1 EDGE: if a player dashes again on the exact tick a prior strike is being drained, the new dash overwrites the window before the drain reads it, so the prior strike's coverage could change. v1 accepts this 1-tick edge (the player chose to re-dash) and the negation reads SourceTick against whatever window is current. If this proves exploitable/confusing in playtest, add a 1-slot window history (PrevStartTick/PrevUntilTick). Flagging so it is an explicit accepted edge, not a silent bug.
- PREDICTION-RECONCILIATION FLICKER is EXPECTED in the very playtest that gates the track (client predicts the hit, server's server-only i-frame negates, next snapshot corrects Health). It is acceptable/server-authoritative, masked by the local i-frame shimmer + hit-feedback suppression. Document this in the fun-gate protocol so a tester does not read it as 'flaky i-frames' and fail the gate spuriously.
- CHARGER STRIKE-BRANCH REWRITE is the largest hidden cost — today EnemyAISystem deals contact damage INSTANTLY on windup-elapse; the Charger needs windup-elapse to instead ENTER a fixed-dir lunge that travels and damages only on contact-during-travel, with whiff detection from SweptMove displacement and NO cancel-on-leave-range. Budget this as a real branch + a SweptMove signature change (return the travel fraction / wall-hit bool), not an additive field. The ground-ring/scale-up telegraph RAMP is net-new CombatFeedbackSystem presentation work (the edge-detect scaffold is reused; the ramped visual is new) — slightly understated as 'mostly tuning' in the spec.
@@ -0,0 +1,66 @@
---
date: 2026-06-09
type: session
tags:
- session
- combat
- mc-0
- mc-1
- netcode
- dash
- charger
permalink: gamevault/07-sessions/2026/2026-06-09-mc1-implementation
---
# MC-0 + MC-1 implementation — dash, Charger, telemetry (code-complete; fun-gate pending)
> Implements [[2026-06-09_MC1_Build_Spec]] (the mandatory pre-code adversarial review's output). Direction: [[Path_to_Fun]] · locks: [[DR-029_Path_A_Fork_Locks]]. **Status: all code + tests + structural Play-validation done; the MC-1 FUN-GATE (feel pass + bench + friend read) is the open operator item — MC-1 is NOT "done" until it passes.**
## What was built (the full uncommitted slice)
### MC-0 — instrument the box
- `DevTelemetry` (Simulation/Debug) — server-only singleton with the four fun-gate counters + LiveEnemyCount/LastSampleTick proof-of-life. `DebugTelemetryReport : IRpcCommand`**unconditional wire type** (RpcCollection hash parity); only the systems are `#if UNITY_EDITOR`.
- `DevTelemetrySystem` (Server) — ensures the singleton, samples each tick, ships the report to every connection every 15 ticks. `DevTelemetryReceiveSystem` (Client) — drains the RPC into the `DevTelemetryReadout` static; `DebugOverlay` renders the live counters.
- **All four counters now wired**: `DashIFrameNegatedHits` (HealthApplyDamageSystem, per negated event) · `DashesWasted` (DashSystem window-close edge, via a new `DashState.NegatedCount` server-written field; close-edge is **server-gated on the singleton** so the client's DashState is never zeroed mid-rollback) · `ChargerWhiffWindowsOpened` (EnemyAISystem, both whiff sites) · `ChargerWhiffPunishesLanded` (HealthApplyDamageSystem: player-sourced hit inside a new `LungeState.StaggerUntilTick` window, **scored once per window** by zeroing on first punish so punishes:windows ≤ 1).
### MC-1 — dash
- `DamageEvent.SourceTick` (non-replicated) stamped via `TickUtil.NonZero` at all THREE append sites (EnemyAISystem melee, ProjectileDamageSystem, TurretFireSystem); `SourceTick==0` = unstamped = never i-framed (fail-safe).
- `DashState` (predicted, non-replicated, re-simulated from input) + `DashCooldown{[GhostField] NextTick}` — baked on the player via PlayerAuthoring. `PlayerInput.Dash` InputEvent (`Fire` twin, command-hash churn only — no ghost re-bake).
- `DashSystem` (predicted, `[UpdateAfter(PlayerControlSystem)]`, Bursted): idempotent start (press + cooldown-ready + not-in-window), HALF-OPEN i-frame window `[StartTick, IFrameUntilTick)`, recovery tail (movement locked, no i-frames), **sharpness-override blink** (`GroundedMovementSharpness` 15→200 for the window — NO CharacterProcessor edit, fully headless, exactly as the spec's confirmed correction predicted).
- Negation in `HealthApplyDamageSystem`: per-element (not whole-buffer), half-open, `NetworkTick` comparisons only (wrap-safe). `PlayerDeathStateSystem` clears the window + restores sharpness on death.
- **Dash juice** in `CombatFeedbackSystem`: dash whoosh SFX + afterimage burst + camera shake + FOV punch on the `DashCooldown.NextTick` edge (muzzle-flash pattern); i-frame shimmer trail each frame the local window is active; **local hit-feedback suppressed during the local i-frame window** (masks the documented prediction-reconciliation Health flicker). All knobs in `FeelConfig` (Feature 5 block, live-pokeable).
### MC-1 — Charger
- `LungeState` (server-only; **component-presence is the discriminator** — no enum in the Bursted system) + `ChargerAuthoring` (composes WITH EnemyAuthoring on the prefab; bakes only LungeState).
- `EnemyAISystem` Charger pass (Grunt pass excludes via `.WithNone<LungeState>()`): precedence **knockback (cancels lunge) > lunge-active (SweptMove travel, contact damage, wall-stop + overshoot whiff → stagger) > seek > commit** — commit locks Dir at windup-elapse and **fires even if the target left range** (the punishable tell). Charger windup 30 ticks / lunge 16 u/s / 18 ticks / stagger 36 ticks (per-pass consts).
- **Prefab chain**: `EnemyCharger.prefab` (capsule template: Enemy.prefab duplicate + ChargerAuthoring + tuned stats HP 45 / spd 2.6 / dmg 14 / cd 48 / scale 1.0) → `EnemyChargerMuscle.prefab` via a new **`EnemyRigTools` "Build Charger (MC-1)"** menu item (builds ONLY the charger so the committed Werewolf/Kaiju outputs aren't re-serialized). Model: **SM_Chr_Muscle_Male_01** (PolygonSciFiCity — the [[Synty_Asset_Inventory]] "verified Generic rig, next-faction" path), atlas `PolygonScifi_01_A` + red tint (danger read), RootY 1.0. Added as the 4th `WaveDirector.EnemyPrefabs` round-robin entry in the Gameplay subscene (additive ghost — no re-hash of existing ghosts).
## Validation (gates 1 + 2 of three)
- **EditMode: 259/259 green** (spec tests incl. half-open boundary, wraparound, per-element, SourceTick-0 fail-safe, idempotent start, cooldown gate, death-mid-dash cleanup, override ordering, Charger commit-out-of-range / whiff-stagger / knockback-cancel, 7 telemetry-counter tests + the post-review rollback-window regression).
- **Play (real netcode session, server+client): zero console errors** — no `ComponentSystemSorter` cycle, no stale-Burst exception. DashState/DashCooldown baked in BOTH worlds; DashSystem sorts directly after PlayerControlSystem (the documented 1-tick fixed-step offset pattern). **Live E2E negation**: armed a window on the server player, appended in-window + out-of-window strikes → only the outside one applied (100→93), counter +1, close-edge cleanup ran. **Live Charger**: spawned from the baked pool → seek → 30-tick telegraph → lunge commit (speed 16) → contact damage → repeat; replicates to the client; killed the (stationary) player → death/respawn flow exercised. **Telemetry pipe**: server counters → RPC → client `DevTelemetryReadout` → overlay, values matching. Charger material values verified (AnimatedLitShader + SciFi atlas + red tint).
- Six "wasted" dashes were counted live with `serverCd == clientCd` — real input-driven dash starts replicated consistently (and wasted-counting works).
## Post-build adversarial review (4 lenses → 12 findings → 2 confirmed, both FIXED)
A 29-agent review workflow (netcode/prediction · DOTS/Burst · spec-adherence · edge-cases, each finding adversarially refuted) ran over the full diff. Ten findings were refuted as true-but-mitigated (by design, world placement, an existing test, or the documented deviations). Two were confirmed and fixed in-session:
1. **MAJOR — the dash override lacked the StartTick lower bound.** `DashState` is non-replicated → NOT restored on prediction rollback; after a press at tick D the client re-simulates pre-dash ticks S..D1 with the post-press window visible, and an upper-bound-only `iFrameActive` stomped dash velocity + sharpness onto ticks that never had them → **dash-start overshoot + snap-back under real latency** (editor RTT≈0 masked it; the negation had the lower bound, only the movement override didn't — the bug originated in the spec's §4 pseudocode). **Fix:** `inDashWindow = StartTick != 0 && !StartTick.IsNewerThan(serverTick)` now gates both override branches; pre-dash re-sim ticks fall to the restore branch. Pinned by `Rollback_ReSimulated_PreDash_Tick_Gets_No_Override`.
2. **MINOR — DashSystem vs HealthApplyDamageSystem were unordered** in the server's predicted group, so a same-tick teammate-projectile vs dash-start negation (`src == StartTick`; ProjectileDamageSystem appends and the drain runs the SAME tick) was an unconstrained sorter tiebreak. **Fix:** `[UpdateAfter(typeof(DashSystem))]` on HealthApplyDamageSystem (chains verified disjoint — no cycle; Play-validated: dash 13 → drain 14, clean world creation).
Notable refuted-but-recorded items: the punish check compares the stagger window against the *drain* tick (correct today — the only `SourceNetworkId>=0` site drains same-tick; revisit if MC-4's cleave appends in the plain group) · the dash displacement consumes velocity one tick after the i-frame window (the documented OrderFirst 1-tick offset; the shimmer matches the true negation window) · the build-server never clears DashState at window-close (intentional — all readers are tick-guarded; pinned by `Without_Telemetry_Singleton_The_Close_Edge_Leaves_DashState_Intact`) · Charger lunge contact only tests the current nearest player (the spec'd + pre-existing Grunt targeting model; a co-op design note for later).
(Process note: the first review run returned `{confirmed: []}` because all four agents died on a subagent session limit — an empty result that masquerades as a clean pass. Check the failures list; re-run after the reset via `resumeFromRunId`.)
## Deviations from the build spec (all deliberate)
1. **Input = direct device reads** (`leftShift` / `buttonEast` `wasPressedThisFrame` in PlayerInputGatherSystem), NOT a `.inputactions` Dash action — kills the only focused-editor step (wrapper regen). Migrate to the action map later if rebinding UI ever needs it.
2. **Per-pass consts for the Charger windup** (30 ticks in the Charger foreach) instead of an `EnemyStats.WindupTicks` field — same effect (Grunt feel untouched), one field less; promote to per-prefab/live-singleton when tuning demands it.
3. **`LungeState.StaggerUntilTick`** added for punish scoring (the spec inferred the window from `EnemyAttackCooldown`, which would conflate post-hit cooldown with stagger and let one window score many punishes).
## Open items (operator)
- **The MC-1 fun-gate** (gate 3): focused-editor feel pass — dash SNAP test (sharpness 200 must read as a blink, else the documented CharacterProcessor fallback + Burst restart), Charger telegraph readability, RootY feet check on the Muscle rig, then the bench (timed vs spam ≥70% fewer hits) + a friend read at Demo A. Live whiff/punish numbers only emerge with a dodging player.
- **TuningConfig live-singleton** (the spec's MC-0 blocker) still does not exist — dash/Charger knobs are baked consts (+ FeelConfig statics for presentation). Decide whether to land it before the tuning pass.
- `Assets/_Recovery/0.unity` (2.2 MB, 2026-06-08 23:36) — an untracked Unity scene-recovery artifact; review + delete (with its .meta) if it holds nothing.
- Wave-spawned Chargers appear only when a siege runs (player-driven pacing) — the round-robin makes 1 in 4 spawns a Charger.
## Links
[[2026-06-09_MC1_Build_Spec]] · [[Path_to_Fun]] · [[DR-028_Combat_Primary_Verb_Depth_First]] · [[DR-029_Path_A_Fork_Locks]] · [[DR-023_Enemy_Animation_MonsterMash]] (rig pipeline) · [[Synty_Asset_Inventory]]
@@ -0,0 +1,39 @@
---
id: DR-028
title: Combat is the primary braided verb — depth-before-breadth, and the combat-depth track
status: accepted
date: 2026-06-08
tags:
- decision
- design
- roadmap
- combat
- scope
permalink: gamevault/07-sessions/decisions/dr-028-combat-primary-verb-depth-first
---
# DR-028 — Combat is the Primary Verb (braid, don't co-equal) + Depth-Before-Breadth
## Context
After M0M7 + inventory/equipment (Phases 01), the project is engineering-rich but "doesn't feel like a game" (operator, 2026-06-08). Diagnosis this session: development has been **breadth-first and correctness-first** — every milestone proved a *system replicates deterministically* (server==client, EditMode green); almost none proved a *loop is fun*. The tell: **"Live interactive fire test" sat OPEN in the [[Backlog]] after a dozen milestones** — combat, pillar #1, was never once playtested for enjoyment. Result: enormous, correct infrastructure over hollow content. Combat = ONE verb (a projectile with stat variants, a single `AbilityRef.Id`); SIX enemy prefabs share ONE brain (`EnemyAISystem` seek-nearest + contact melee); NO dodge/dash; death = free respawn. "Skill expression over stat-checks" is unmet because there is no skill surface — and aim is already decoupled from move, so kite-strafe-and-click trivially beats the only brain.
The four [[Pillars]] read as **four co-equal genres** — action combat + co-op base + automation + netcode. That is three deep genres in one game, which a solo dev (even Claude-accelerated) cannot make co-equally deep, and building them all breadth-first is *why* there is no fun. Operator intake (2026-06-08): wants ALL THREE (the fusion is the identity), **co-op NON-NEGOTIABLE**, **passion/craft** (no deadline), **solo + Claude** (content-light). Grounding in real small-studio games that fuse combat+base+automation — **The Riftbreaker** (combat-led), **Core Keeper** (mining-led, literal conveyors+drills, co-op) — confirms the fusion is achievable but ALWAYS picks one primary verb and braids the others around it; none makes all three co-equal.
## Decision
1. **Combat is the PRIMARY VERB.** Base-building and automation are not cut — they **braid around combat** as its stakes and economy: automation makes what you fight *with* (charges/munitions/turrets/upgrades); sieges threaten what automation lives *in* (the base/machines, with a real loss beat); the sortie feeds both. See [[Path_to_Fun]]. "All three" stays the identity, but as ONE braided loop with a single primary verb — never three co-equal, independently-deep modes (which is the current state and the thing that doesn't feel like a game).
2. **Why combat (not base/automation) is the verb:** co-op-non-negotiable points at *shared combat* (the most reliable co-op fun — Deep Rock Galactic / Risk of Rain 2 / Vermintide); combat is structurally furthest along; combat is the most hollow, so it has the highest fun-per-hour return.
3. **Depth-before-breadth is the operating rule.** No new SYSTEM until one braided loop is genuinely fun. The validation culture shifts: green EditMode + server==client are **necessary, not sufficient** — every milestone now ends with a **fun-gate** (play it, with a friend, and not want to stop). The netcode/determinism rigor stays (it is a real strength); "done" stops meaning "tests pass."
4. **Pause inventory/equipment Phases 24 and automation breadth** (recipe/throughput). They are more breadth on systems whose payoff is combat power — premature while combat is hollow. Redirect to the combat-depth track. The shipped Phases 01 stand; only the forward phases pause.
5. **The combat-depth track** ([[Path_to_Fun]], MC-1…MC-6) is the next work, designed this session via a multi-agent pass (5 design lenses grounded in real games + the actual DOTS code → synthesis → 3 adversarial critics: netcode-feasibility / solo-scope / fun, all "go-with-changes"). Keystone: a **dodge + a committed, whiff-punishable enemy + a readable telegraph** = the smallest "fight in a box" that turns stand-and-click into a conversation. The dash is the *answer*, the committed lunge is the *question*; they ship together.
## Consequences
- **The braid is the highest-leverage design change** and costs less than half of what's already built — most parts exist; they're pointed at a ledger instead of at each other.
- **Pillar ordering changes; no code is deleted.** [[Pillars]] revised to name combat the primary braided verb + the depth-before-breadth rule; [[Identity]] gains the braid section. The automation/base records ([[DR-014_M6_Build_Structures_Automation_Foundation]] / [[DR-020_M7_Automation_Production_Chains]]) stand — their role becomes "support the fight."
- **Combat was never play-tuned**, so MC-1 onward validates by PLAYING, not tests. The team's reflex is correctness; the discipline now is feel.
- **Two MC-1 netcode blockers are pre-caught** (must be honored at code time): (a) the dash i-frame must negate damage per-`DamageEvent` against the tick it was authored (`HealthApplyDamageSystem` runs in the predicted group and drains the prior-tick melee event) — stamp a non-replicated `uint SourceTick` on `DamageEvent`, not "is DashState active now"; (b) `CharacterControl` has no sharpness field — a flat `MoveVelocity` write ramps ("walk faster"); also drive `CharacterComponent.GroundedMovementSharpness` near-instant for the dash window. Plus the precondition: telegraphs must read off a **replicated absolute-tick countdown** (`AttackWindup`-style `[GhostField] uint`), not interpolated motion (~100ms late). Details in [[Path_to_Fun]].
- **Falsifiable / owner-revisitable:** if, building the braid, the factory turns out to be the operator's true love, re-cut around automation-as-heart (Factorio/Dyson model, combat as defense). The combat-first call is a recommendation the operator can reverse.
- **Refined 2026-06-08 (same session)** via a second multi-agent pass (4 deepen lenses → synthesis → 3 code-grounded adversarial critics, all go-with-changes): [[Path_to_Fun]] is now split into a **committed Path A** (MC-0/MC-1/MC-4/EB-1/EB-2/END-1/END-2 — the minimal path to *fight-fun + braided-with-stakes + a win/lose condition*, a shippable game-with-a-point) and a **provisional, NOT-scheduled Path B** forever-track, with a **mandatory logged Decision Gate** after END-2 before any Path B work (the enforcement teeth of depth-before-breadth). The critics' code audit added a **third `DamageEvent` stamp site** (`TurretFireSystem`) to the i-frame `SourceTick` fix and required `DashState` carry an explicit `StartTick` (the window-start the negation compares against, via `NetworkTick` — the strike is appended a tick earlier in a different system group than the drainer). Net-new replicated state stays minimal — structure `Health` (EB-1) and `CoreIntegrity`/`RunPhase`/`RunOutcome` bytes on the existing global ghost (END-1/2). Ten operator forks are catalogued in [[Path_to_Fun]] (top: lose-severity, enemy-aggro, charge-cadence).
- Supersedes the implicit "four co-equal pillars" framing of [[Pillars]]; sets [[Path_to_Fun]] as the new north-star roadmap. [[Milestones]] remains the historical record.
@@ -0,0 +1,44 @@
---
id: DR-029
title: Path A forks locked + the present-the-forks ritual (no auto-deciding gameplay)
status: accepted
date: 2026-06-09
tags:
- decision
- design
- roadmap
- combat
- process
permalink: gamevault/07-sessions/decisions/dr-029-path-a-fork-locks
---
# DR-029 — Path A Forks Locked + the Present-the-Forks Ritual
## Context
[[Path_to_Fun]] (refined per [[DR-028_Combat_Primary_Verb_Depth_First]]) carried ~20 open forks — the committed **Path A** could not be built without locking the ones that shape its feel. The operator made a **process correction**: gameplay-design forks are the operator's to call — **present each fork with a recommendation and let the operator decide; never auto-decide a gameplay question or mark a default "official" without an explicit okay.** (An attempt to auto-lock every fork via a workflow was halted mid-run.) The forks were then worked through interactively.
## Decision
**1. Path A forks are LOCKED** (2026-06-09), via a present-the-forks exercise. The set (also tabled in [[Path_to_Fun#Locked decisions (Path A)]]):
- **MC-1 dash:** free aim during the dash · whole-window i-frames (the recovery tail punishes spam).
- **MC-1 Charger:** a new Husk **variant prefab** (distinct silhouette + telegraph), not a brain-byte.
- **MC-4 cleave:** its **own button** + its **own cooldown** (dash→cleave→shoot stays live).
- **EB-1 aggro:** Husks **push for the base/structures** (attacking players in the way) — you defend; live singleton.
- **EB-1 / END-1 persistence:** a **wounded base persists** across save/quit (structure HP + Core integrity in SaveData v3).
- **EB-2 ammo:** **turret ammo only** (server-only) from the factory; player abilities stay free for now. **One** munition type ("Charge"). Run-dry = **soft-fail** (turrets go quiet).
- **END-1 lose:** **soft loss** — a breach drains the shared ledger / damages structures, the siege ends, the base persists wounded (Tuning byte). A breach drains the **Core bar only**; structure destruction stays EB-1's job.
- **END-2 win:** the meter fills from **BOTH** surviving sieges AND Aether deposited at the Engine (the operator's one non-default pick — the stronger braid; two server-only single writers summed into `GoalProgress.Charge`). Winning = **keep playing** (the base is yours; NG+/endless is the later END-5). Final beat = **one big escalating wave** (boss deferred).
- **`EnemyStatus`** co-op damage-amp stays in **MC-5**.
Live-singleton picks remain tunable at playtest (a lock is a starting default, not a cage). The one departure from the safe default — **charge cadence = both** — wires the win condition into the economy braid and costs END-2 a little extra deposit-charge wiring.
**2. The fork-locking ritual is a standing process rule.** Path B forks stay **open**; before building any Path B milestone (chosen at the [[Path_to_Fun#The Decision Gate (MANDATORY STOP after END-2)|Decision Gate]]), **its forks are locked first via this same present-the-forks exercise.** No auto-deciding gameplay-design questions.
## Consequences
- Path A is concretely specified — Claude can build MC-0 + the MC-1 code/tests autonomously; the first genuine operator touchpoints are the **Burst-affecting `CharacterProcessor` edit** (focused editor) and the **MC-1 fun-gate** (feel + ideally a friend).
- [[Path_to_Fun]]'s "Open decisions" section is replaced by **Locked decisions (Path A)** + the ritual note; [[Backlog]] updated.
- Reversible by the same ritual — live-singleton locks flip at playtest; structural locks (cadence-both, persistence v3, turret-only ammo) are committed shapes a re-run can revisit.
- Reinforces [[DR-028_Combat_Primary_Verb_Depth_First]]'s depth-before-breadth: Path B is not pre-decided, so breadth cannot creep in via a stale default.