diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs
index b66365725..fcc4645ee 100644
--- a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs
+++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs
@@ -10,8 +10,8 @@ namespace ProjectM.Authoring
/// AbilityFireSystem — only the Vortex FLAG is authored here (so AbilityFireSystem can pick
/// ZoneEffect vs DecoyTag by prefab membership, and the pull behaviour is baked-in). The prefab's ghost setup
/// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing ownerless
- /// interpolated ghost, so it replicates to all clients via the stock LocalTransform variant (no hand-written
- /// [GhostField]). GetEntity(Dynamic) gives a runtime-mutable LocalTransform for the spawn override.
+ /// interpolated ghost, so position replicates via the stock LocalTransform variant. 07-21 G6: ZoneEffect's
+ /// CasterNetworkId / Radius / NextTick are hand-written [GhostField]s (the client fill telegraph). GetEntity(Dynamic) gives a runtime-mutable LocalTransform for the spawn override.
///
public class ZoneAuthoring : MonoBehaviour
{
diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs
index dc7d16876..e5dbe302e 100644
--- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs
+++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs
@@ -81,6 +81,7 @@ namespace ProjectM.Authoring
// MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero.
AddComponent(entity);
AddComponent(entity); // 07-20 G2.1: server-only scheduled cleave (baked zeroed, not replicated)
+ AddComponent(entity); // 07-21 G6: server-only scheduled Cone-socket slam (baked zeroed, not replicated)
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
// plus the server-only respawn timer.
diff --git a/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs
new file mode 100644
index 000000000..cffda00e3
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs
@@ -0,0 +1,22 @@
+#if UNITY_EDITOR
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ /// 07-21 G4 (review wf_98bf1268) — editor-only saturation-stress toggle, flipped by the DebugOverlay
+ /// row. While ON, CombatFeedbackSystem synthesizes the ally attack package at 3 orbiting fake-caster positions
+ /// (the 4-player worst case without 4 connections). Lives in the Debug family, NOT FeelConfig —
+ /// FeelProfileService.SaveCurrent snapshots every FeelConfig field and a captured profile would re-arm stress
+ /// mode on Apply (review finding). Resets on play-enter per the static-presentation-bridge ★ rule (statics
+ /// survive fast-enter-playmode reloads; a leaked-on flag would spew phantom ally FX with no overlay in
+ /// Game.unity to clear it).
+ public static class CombatStressDebug
+ {
+ /// While true, CombatFeedbackSystem emits the fake-caster ally-FX package every ~0.5 s.
+ public static bool StressAllyFx;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetOnEnterPlayMode() => StressAllyFx = false;
+ }
+}
+#endif
diff --git a/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta
new file mode 100644
index 000000000..a21ed50a6
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e0854c467017c474b8eac1fe74950370
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
index 305d0ec13..0e1313f22 100644
--- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
+++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
@@ -40,6 +40,11 @@ namespace ProjectM.Client
if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves();
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
+ GUILayout.Space(6);
+ GUILayout.Label("- Saturation (G4) -");
+ if (GUILayout.Button("Spawn 12 Drowners")) DebugCommandSendSystem.SpawnEnemy(0, 12); // gym roster kind 0; the standing worst-case load
+ CombatStressDebug.StressAllyFx = GUILayout.Toggle(CombatStressDebug.StressAllyFx, "Stress ally FX (3 fake casters)");
+
GUILayout.Space(6);
GUILayout.Label("- Resources -");
_grantAmount = IntField("Amount", _grantAmount);
diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
index 06232458c..e25e4c153 100644
--- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
@@ -67,6 +67,14 @@ namespace ProjectM.Client
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
int _pendingConnectStep;
+ uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
+ float _pendingConeRange; // cone reach/half latched at the fire edge (folded socket stats)
+ float _pendingConeHalf;
+ float _allyFxScale = 1f; // 07-21 G4: saturation-derived ally-FX degradation (1 = full loudness)
+#if UNITY_EDITOR
+ float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
+ int _stressBeat;
+#endif
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
@@ -106,10 +114,13 @@ namespace ProjectM.Client
const int NumberPoolSize = 32;
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
+ EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
struct TimedVfx { public GameObject Go; public double Kill; }
protected override void OnCreate()
{
+ // 07-21 G4 (review wf_9757d214): remote-player census — solo (0 remotes) forces _allyFxScale = 1.
+ _remotePlayersQuery = SystemAPI.QueryBuilder().WithAll().WithDisabled().Build();
_hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true);
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
@@ -232,7 +243,7 @@ namespace ProjectM.Client
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
- PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
+ PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
@@ -282,7 +293,15 @@ namespace ProjectM.Client
{
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
PlayClip(_deathClip, (Vector3)p, 0.7f);
- PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake);
+ PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
+ if (isLocalPlayer)
+ {
+ // Post-impl review wf_9757d214: the server dropped any scheduled damage on death
+ // (PlayerDeathStateSystem zeroes both pendings) — drop the latched connect CUES too,
+ // or the corpse plays a full connect package for a hit that never landed.
+ _pendingConnectTick = 0u;
+ _pendingConeConnectTick = 0u;
+ }
}
}
else if (isEnemy && _scanPrimed && cur >= health.ValueRO.Max - 0.001f)
@@ -334,6 +353,34 @@ namespace ProjectM.Client
}
}
+ // 07-21 G4 (SoD's co-op saturation failure is our ceiling): ally-attributed FX degrade as the live
+ // enemy count rises — remote arcs dim, remote shakes shrink. LOCAL-player FX are untouched and enemy
+ // telegraphs NEVER degrade (EnemyDangerTelegraphSystem is structurally separate; guidelines G4).
+ int liveEnemies = 0;
+ foreach (var kv in _cache) if (kv.Value.IsEnemy && kv.Value.Hp > 0f) liveEnemies++; // LIVING only (review wf_9757d214: Dying corpses linger ~1s in the cache)
+ _allyFxScale = _remotePlayersQuery.CalculateEntityCount() == 0
+ ? 1f // solo: no ally FX exist to budget — never degrade the local player's own feedback (review wf_9757d214)
+ : SaturationMath.AllyScale(liveEnemies,
+ FeelConfig.AllyFxDegradeStart, FeelConfig.AllyFxDegradeFull, FeelConfig.AllyFxFloor);
+#if UNITY_EDITOR
+ // 07-21 G4: fake-caster saturation stress (DebugOverlay toggle) — synthesizes the ally attack package
+ // at 3 orbiting positions so the 4-caster worst case is testable without 4 connections. Editor-only;
+ // the flag resets on play-enter (CombatStressDebug, the static-presentation-bridge rule).
+ if (CombatStressDebug.StressAllyFx && _localPlayer != Entity.Null && UnityEngine.Time.time >= _nextStressTime)
+ {
+ _nextStressTime = UnityEngine.Time.time + 0.5f;
+ _stressBeat++;
+ for (int fake = 0; fake < 3; fake++)
+ {
+ float ang = _stressBeat * 0.7f + fake * 2.094f;
+ Vector3 fpos = (Vector3)localPos + new Vector3(Mathf.Cos(ang), 0f, Mathf.Sin(ang)) * 3.5f;
+ EmitTinted(_swingFx, fpos + Vector3.up * 0.9f,
+ (int)Mathf.Ceil(8f * _allyFxScale), FeelConfig.RemoteSlashColor * _allyFxScale);
+ PlayClip(_swingClip, fpos, 0.3f * _allyFxScale);
+ }
+ }
+#endif
+
// LANTERN 4-socket fire feedback: edge-detect each socket's SocketCooldown (raw uint edge, cosmetic
// only like dash/melee). A non-Cone Spark -> muzzle flash + zap; a Cone Spark -> the aimed slash-arc
// cue (server-only cleave has no projectile). Replaces the single-AbilityCooldown muzzle + cone blocks.
@@ -379,28 +426,39 @@ namespace ProjectM.Client
var es = effs[sk];
float coneRange = Mathf.Max(0.1f, es.Range);
float coneHalf = Mathf.Clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f);
- bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
- float coneCos = Mathf.Cos(coneHalf);
- foreach (var kv in _cache)
- {
- if (!kv.Value.IsEnemy) continue;
- if (MeleeConeMath.InCone(localPos, sfdir, coneRange, coneCos, kv.Value.Pos))
- {
- float cd2 = math.distancesq(localPos, kv.Value.Pos);
- if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
- }
- }
- TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, coneConnected);
+ // 07-21 G6 (review wf_98bf1268): the slam's damage now lands at fire+ConeContactTicks — the arc
+ // reveal completes AT that contact (C13) and the connect package is LATCHED to it via the C14
+ // idiom (knob 0 = legacy immediate). Defaults() fallback matches the release server's timing.
+ var coneTcfg = SystemAPI.TryGetSingleton(out var coneTcv) ? coneTcv : TuningConfig.Defaults();
+ uint coneContactTicks = (uint)math.max(0f, coneTcfg.ConeContactTicks);
+ float coneLife = Mathf.Max(0.34f, (coneContactTicks / 60f) / 0.6f);
+ TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, false, coneLife);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
- if (coneConnected)
+ _pendingConeRange = coneRange;
+ _pendingConeHalf = coneHalf;
+ if (coneContactTicks == 0u)
{
- Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
- PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
- PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
+ _pendingConeConnectTick = 0u;
+ EvaluateConeConnect(localPos); // knob 0 = legacy same-tick connect
+ }
+ else
+ {
+ _pendingConeConnectTick = TickUtil.NonZero(
+ TickWindowMath.FireStartRaw(nf, es.CooldownTicks) + coneContactTicks);
}
}
_socketFireInit = true;
+
+ // 07-21 G6: fire the deferred cone CONNECT when the slam lands (contact tick reached; wrap-safe;
+ // the C14 idiom — latched once at the fire edge, never reconstructed per-frame).
+ if (_pendingConeConnectTick != 0u
+ && SystemAPI.TryGetSingleton(out var coneNt) && coneNt.ServerTick.IsValid
+ && !new NetworkTick(_pendingConeConnectTick).IsNewerThan(coneNt.ServerTick))
+ {
+ EvaluateConeConnect(localPos);
+ _pendingConeConnectTick = 0u;
+ }
}
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
@@ -444,7 +502,8 @@ namespace ProjectM.Client
EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5);
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
PrototypeCameraRig.AddShake(0.04f * step);
- int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
+ var tcfg = SystemAPI.TryGetSingleton(out var tcv2) ? tcv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback = Defaults(), matching the server sim
+ int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3);
bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.2f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
@@ -480,7 +539,8 @@ namespace ProjectM.Client
&& SystemAPI.TryGetSingleton(out var meleeNt) && meleeNt.ServerTick.IsValid
&& !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick))
{
- int cLen = SystemAPI.TryGetSingleton(out var ct2) ? (int)math.clamp((int)ct2.MeleeComboLength, 1, 3) : 3;
+ var ct2 = SystemAPI.TryGetSingleton(out var ctv2) ? ctv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback
+ int cLen = (int)math.clamp((int)ct2.MeleeComboLength, 1, 3);
EvaluateMeleeConnect(localPos, _pendingConnectStep, cLen);
_pendingConnectTick = 0u;
}
@@ -888,12 +948,11 @@ namespace ProjectM.Client
var cfg = VFXConfig.Instance;
bool finisher = step >= comboLen;
float range = 2.2f, half = 0.9f, finRange = 1.25f;
- if (SystemAPI.TryGetSingleton(out var tcfg))
- {
- if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange;
- if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad;
- if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult;
- }
+ // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing.
+ var tcfg = SystemAPI.TryGetSingleton(out var mcv) ? mcv : TuningConfig.Defaults();
+ if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange;
+ if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad;
+ if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult;
if (EntityManager.HasBuffer(_localPlayer))
range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange,
EntityManager.GetBuffer(_localPlayer, true)));
@@ -903,17 +962,7 @@ namespace ProjectM.Client
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData(_localPlayer).Aim,
EntityManager.GetComponentData(_localPlayer).Direction);
- bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
- float cosHalf = Mathf.Cos(half);
- foreach (var kv in _cache)
- {
- if (!kv.Value.IsEnemy) continue;
- if (MeleeConeMath.InCone(localPos, fdir, range, cosHalf, kv.Value.Pos))
- {
- float d2 = math.distancesq(localPos, kv.Value.Pos);
- if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
- }
- }
+ bool connected = NearestEnemyInCone(localPos, fdir, range, Mathf.Cos(half), out var nearestHit); // review wf_98bf1268: shared scan
if (connected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
@@ -930,6 +979,42 @@ namespace ProjectM.Client
}
}
+ // 07-21 G6 (review wf_98bf1268): ONE nearest-living-enemy-in-cone scan over the FX cache — shared by the
+ // socket-fire cue, the melee connect package and the deferred cone connect (three copies would drift).
+ bool NearestEnemyInCone(float3 pos, float2 dir, float range, float cosHalf, out Vector3 hit)
+ {
+ hit = (Vector3)pos; float best = float.MaxValue; bool found = false;
+ foreach (var kv in _cache)
+ {
+ if (!kv.Value.IsEnemy) continue;
+ if (!MeleeConeMath.InCone(pos, dir, range, cosHalf, kv.Value.Pos)) continue;
+ float d2 = math.distancesq(pos, kv.Value.Pos);
+ if (d2 < best) { best = d2; hit = (Vector3)kv.Value.Pos; found = true; }
+ }
+ return found;
+ }
+
+ // 07-21 G6: the cone socket's CONNECT package at the CONTACT tick — the EvaluateMeleeConnect mirror for
+ // the SpecialSlam (aim recomputed LIVE at contact; range/half latched at the fire edge from the socket's
+ // folded stats). Fired by the deferred check next to the socket-fire branch (C14 idiom).
+ void EvaluateConeConnect(float3 localPos)
+ {
+ if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return;
+ var cfg = VFXConfig.Instance;
+ float2 fdir = new float2(0f, 1f);
+ if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer))
+ fdir = FacingMath.ResolveAim(
+ EntityManager.GetComponentData(_localPlayer).Aim,
+ EntityManager.GetComponentData(_localPlayer).Direction);
+ if (!NearestEnemyInCone(localPos, fdir, _pendingConeRange, Mathf.Cos(_pendingConeHalf), out var hit)) return;
+ Burst(_hitFx, cfg != null ? cfg.Hit : null, hit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
+ PlayClip(_meleeConnectClip, hit, FeelConfig.MeleeConnectVolume);
+ PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
+ if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
+ RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
+ if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
+ }
+
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
{
@@ -997,7 +1082,10 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
void UpdateRemoteSwings(float dt)
{
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;
- int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
+ // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing
+ // (the dev TuningConfig singleton is editor-only; default(TuningConfig) reads knob 0 = legacy cues).
+ var tcfg = SystemAPI.TryGetSingleton(out var rtcv) ? rtcv : TuningConfig.Defaults();
+ int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3);
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
float finisherMult = tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult
@@ -1023,7 +1111,7 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
rs.Range = finisher ? rRange * finisherMult : rRange;
rs.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
- rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
+ rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f) * _allyFxScale; // 07-21 G4: ally FX dim under saturation (brightness only — Life stays contact-honest, C13)
rs.Life = Mathf.Max(finisher ? 0.50f : 0.34f,
(MeleeTiming.ContactTicks((byte)step, remoteContactKnob) / 60f) / 0.6f); // 07-20: reveal ends AT contact (knob-aware, review C13)
rs.Age = 0f;
diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
index 74c7373a0..64e95b771 100644
--- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
@@ -204,5 +204,32 @@ namespace ProjectM.Client
m.RecalculateBounds();
return m;
}
+
+ // Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE
+ // damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner
+ // edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it.
+ public static Mesh BuildRing(int segments, float innerFrac = 0.92f)
+ {
+ if (segments < 6) segments = 6;
+ innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f);
+ var m = new Mesh { name = "Ring" };
+ var v = new Vector3[segments * 2];
+ var tris = new int[segments * 6];
+ for (int i = 0; i < segments; i++)
+ {
+ float a = i / (float)segments * Mathf.PI * 2f;
+ var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
+ v[i * 2] = dir * innerFrac;
+ v[i * 2 + 1] = dir;
+ int n = (i + 1) % segments;
+ int b = i * 6;
+ tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1;
+ tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1;
+ }
+ m.vertices = v;
+ m.triangles = tris;
+ m.RecalculateBounds();
+ return m;
+ }
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs
index 0872fead3..4fbab3edd 100644
--- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs
@@ -222,6 +222,14 @@ namespace ProjectM.Client
/// Marker glyph font size (px).
public static float EnemyMarkerSize;
+ // ---- 07-21 G4: co-op saturation budget (guidelines G4 — SoD's failure is our ceiling) ----
+ /// Live enemy count at which ALLY-attributed FX (remote arcs, remote shakes) begin to degrade. Local-player FX and enemy telegraphs NEVER degrade.
+ public static int AllyFxDegradeStart;
+ /// Live enemy count at which ally FX sit at AllyFxFloor.
+ public static int AllyFxDegradeFull;
+ /// Ally-FX brightness/shake floor under full saturation (0..1).
+ public static float AllyFxFloor;
+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
@@ -328,6 +336,10 @@ namespace ProjectM.Client
EnemyMarkerMinAlpha = 0.28f;
EnemyMarkerColor = new Color(1f, 0.86f, 0.3f, 1f); // warm amber, reads over the cool world
EnemyMarkerSize = 24f;
+ // 07-21 G4 co-op saturation budget (review wf_98bf1268)
+ AllyFxDegradeStart = 8;
+ AllyFxDegradeFull = 16;
+ AllyFxFloor = 0.35f;
}
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs
new file mode 100644
index 000000000..27778e3e3
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs
@@ -0,0 +1,24 @@
+using Unity.Mathematics;
+
+namespace ProjectM.Client
+{
+ /// 07-21 G4 (review wf_98bf1268) — pure co-op saturation-budget math (no ECS; unit-tested like
+ /// HudVisualMath, the Client-side presentation-math precedent). Maps a live on-screen enemy count to
+ /// the ALLY-FX degradation scale: 1 at <= start, linear down to at >= full.
+ /// Enemy telegraphs never ride this (guidelines G4: they never degrade); local-player FX never ride this.
+ /// Note: EnemyMarkerSystem's pip fade equals AllyScale(count, start, 2*start, floor) — a later
+ /// pass can retrofit it onto this helper.
+ public static class SaturationMath
+ {
+ /// 1 at enemies <= start; linear to at enemies >= full; clamped.
+ /// Degenerate inputs (full <= start) snap straight to floor once past start.
+ public static float AllyScale(int enemies, int start, int full, float floor)
+ {
+ floor = math.clamp(floor, 0f, 1f);
+ if (enemies <= start) return 1f;
+ if (full <= start) return floor;
+ float t = math.saturate((enemies - start) / (float)(full - start));
+ return math.lerp(1f, floor, t);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta
new file mode 100644
index 000000000..ed1074c2a
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 93f34c59962a12243aec9840a608e2da
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs
new file mode 100644
index 000000000..68d6c2eb9
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs
@@ -0,0 +1,207 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+using static ProjectM.Client.FeedbackFx;
+
+namespace ProjectM.Client
+{
+ ///
+ /// 07-21 G6 (review wf_98bf1268) — client-only ZONE-socket fill telegraph (Vortex / LightZone), the
+ /// WildStar-grade honesty decal for player zones: a thin RIM always drawn at the replicated
+ /// (rim = the TRUE folded damage radius, guidelines G2) plus an inner FILL
+ /// disc whose arrival at the rim IS the damage moment — fill derives from the replicated absolute
+ /// over ; each server re-stamp
+ /// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only
+ /// in , templated on
+ /// (shared /
+ /// unit meshes + MPB alpha + pooled GOs + per-frame silent prune; the value-latch + was-counting-down
+ /// arm-guard pulse contract, review H1 — never edge-detect the re-stamp).
+ ///
+ /// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against
+ /// NetworkTime.InterpolationTick, NOT the predicted ServerTick — a geyser threatens the PREDICTED
+ /// local player, but a zone's observables (enemy HP drops, vortex pull) live on the INTERPOLATED timeline
+ /// the zone ghost itself renders on; the predicted tick would complete the fill ~RTT early and pin it at
+ /// full (invisible on loopback, 20-40% of the bar wrong at internet RTTs — review finding, confirmed).
+ /// Ownership tint per guidelines G3: local caster = warm (LightZone) / teal (Vortex); ally = dimmer
+ /// cool-blue of the same shapes; never red. Enemy telegraphs live elsewhere and NEVER degrade (G4).
+ ///
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class ZoneTelegraphSystem : SystemBase
+ {
+ static readonly Color LocalLightColor = new Color(2.2f, 1.6f, 0.7f); // warm lamp-amber — light is territory
+ static readonly Color LocalVortexColor = new Color(0.5f, 1.9f, 1.8f); // teal swirl
+ static readonly Color AllyColor = new Color(0.35f, 0.55f, 1.1f); // dim cool-blue (same shapes, G3)
+ static readonly int ColorId = Shader.PropertyToID("_Color");
+ const float k_PulseFlashSeconds = 0.14f;
+
+ Transform _fxRoot;
+ Material _mat;
+ Mesh _discMesh;
+ Mesh _ringMesh;
+ MaterialPropertyBlock _mpb;
+
+ // Per-zone pooled pair: [0] = fill disc, [1] = rim ring (children of one root GO).
+ readonly Dictionary _zones = new();
+ readonly Dictionary _armed = new(); // NextTick seen counting down (arm-guard)
+ readonly Dictionary _lastFired = new(); // NextTick we last pulsed for (value latch)
+ readonly Dictionary _flashUntil = new();
+ readonly HashSet _seen = new();
+ readonly List _stale = new();
+
+ protected override void OnCreate()
+ {
+ _mpb = new MaterialPropertyBlock();
+ }
+
+ protected override void OnStartRunning()
+ {
+ if (_fxRoot != null) return;
+ _fxRoot = new GameObject("~ZoneTelegraphFX").transform;
+ _mat = MakeParticleMaterial("ZoneTelegraph");
+ _discMesh = BuildDisc(40);
+ _ringMesh = BuildRing(48);
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
+ if (_mat != null) Object.Destroy(_mat);
+ if (_discMesh != null) Object.Destroy(_discMesh);
+ if (_ringMesh != null) Object.Destroy(_ringMesh);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (_fxRoot == null || _mat == null) return;
+ if (!SystemAPI.TryGetSingleton(out var nt)) return;
+ // The interpolated timeline — see the class doc for why NOT the predicted ServerTick.
+ var tick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick;
+ if (!tick.IsValid) return;
+ int localNetId = SystemAPI.TryGetSingleton(out var nid) ? nid.Value : -1;
+
+ EntityManager.CompleteDependencyBeforeRO();
+ EntityManager.CompleteDependencyBeforeRO();
+
+ _seen.Clear();
+ foreach (var (zone, xf, e) in
+ SystemAPI.Query, RefRO>().WithEntityAccess())
+ {
+ var ze = zone.ValueRO;
+ _seen.Add(e);
+ if (ze.Radius <= 0.01f) continue; // pre-first-snapshot (baked zero) — nothing honest to draw yet
+
+ float3 pos = xf.ValueRO.Position;
+ bool mine = ze.CasterNetworkId == localNetId;
+ bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0;
+ Color baseCol = mine ? (vortex ? LocalVortexColor : LocalLightColor) : AllyColor;
+
+ // Fill from the replicated ABSOLUTE next-pulse tick (0 = unscheduled -> rim only).
+ float fill = 0f;
+ uint next = ze.NextTick;
+ if (next != 0u && new NetworkTick(next).IsValid)
+ {
+ int lead = new NetworkTick(next).TicksSince(tick); // >0 counting down, <=0 arrived/past
+ if (lead > 0)
+ {
+ _armed[e] = next; // arm this pulse while it counts down
+ // Operator report 07-21 ("casts seem to auto-recast"): a fill RE-GROWING every pulse reads
+ // as a fresh cast. ARM phase (before the first pulse) grows 0->1 (fill reaches the rim =
+ // first damage); the PERSISTENT phase DRAINS 1->0 toward each pulse (a metronome, not a
+ // cast) - and the direction split is the G6/R6 one-shot-vs-persistent encoding, done right.
+ float frac = math.saturate(lead / (float)ZoneEffect.PulsePeriodTicks);
+ fill = _lastFired.ContainsKey(e) ? frac : 1f - frac;
+ }
+ else
+ {
+ fill = 1f;
+ bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
+ bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
+ if (armedForThis && !alreadyFired)
+ {
+ _flashUntil[e] = UnityEngine.Time.time + k_PulseFlashSeconds; // quiet tier: a rim flash, no burst (G4)
+ _lastFired[e] = next;
+ }
+ }
+ }
+
+ if (!_zones.TryGetValue(e, out var go) || go == null)
+ {
+ go = new GameObject("ZoneTelegraph");
+ go.transform.SetParent(_fxRoot, false);
+ MakeChild(go.transform, "Fill", _discMesh);
+ MakeChild(go.transform, "Rim", _ringMesh);
+ _zones[e] = go;
+ }
+ if (!go.activeSelf) go.SetActive(true);
+ go.transform.position = new Vector3(pos.x, 0.05f, pos.z);
+
+ bool flashing = _flashUntil.TryGetValue(e, out var fu) && UnityEngine.Time.time < fu;
+ var fillTr = go.transform.GetChild(0);
+ var rimTr = go.transform.GetChild(1);
+ float fillRadius = ze.Radius * fill;
+ fillTr.localScale = new Vector3(fillRadius, 1f, fillRadius);
+ rimTr.localScale = new Vector3(ze.Radius, 1f, ze.Radius);
+
+ float fillAlpha = (mine ? 0.16f : 0.10f) * (0.35f + 0.65f * fill);
+ float rimAlpha = (mine ? 0.55f : 0.35f) + (flashing ? 0.4f : 0f);
+ SetTint(fillTr, baseCol, fillAlpha);
+ SetTint(rimTr, baseCol, rimAlpha);
+ }
+
+ // Prune despawned zones (expiry / teardown / relevancy drop) — destroy, drop tracking, emit nothing.
+ if (_zones.Count > 0)
+ {
+ _stale.Clear();
+ foreach (var kv in _zones) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++)
+ {
+ if (_zones[_stale[i]] != null) Object.Destroy(_zones[_stale[i]]);
+ _zones.Remove(_stale[i]);
+ }
+ }
+ PruneMap(_armed);
+ PruneMap(_lastFired);
+ PruneFloatMap(_flashUntil);
+ }
+
+ void MakeChild(Transform parent, string name, Mesh mesh)
+ {
+ var child = new GameObject(name);
+ child.transform.SetParent(parent, false);
+ child.AddComponent().sharedMesh = mesh;
+ var mr = child.AddComponent();
+ mr.sharedMaterial = _mat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ }
+
+ void SetTint(Transform tr, Color c, float alpha)
+ {
+ _mpb.SetColor(ColorId, new Color(c.r, c.g, c.b, alpha));
+ tr.GetComponent().SetPropertyBlock(_mpb);
+ }
+
+ void PruneMap(Dictionary dict)
+ {
+ if (dict.Count == 0) return;
+ _stale.Clear();
+ foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
+ }
+
+ void PruneFloatMap(Dictionary dict)
+ {
+ if (dict.Count == 0) return;
+ _stale.Clear();
+ foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta
new file mode 100644
index 000000000..3396b7b2e
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7546a7bb0f6fd634287c3d5b98faa28c
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs
index e66f694e7..f3f96ee80 100644
--- a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs
+++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs
@@ -93,8 +93,14 @@ namespace ProjectM.EditorTools
}
else sb.AppendLine(" Reach: weapon mesh not found (run Attach Melee Weapon first)");
- sb.AppendLine(" NOTE: SpecialSlam (cone socket) still damages AT FIRE, ~0.35s before its visual contact — the");
- sb.AppendLine(" same dishonesty melee just fixed; it rides the SOCKET pipeline (guidelines G6 follow-up).");
+ // 07-21 G6 (review wf_98bf1268): the cone socket (SpecialSlam) lands at ConeContactTicks via
+ // ConeContactPending (0 = legacy at-fire). Visual contact estimate ~0.35s (21t) into the slam anim.
+ uint coneContact = (uint)Mathf.Max(0f, t.ConeContactTicks);
+ sb.AppendLine(coneContact == 0
+ ? " Cone (SpecialSlam): knob 32 = 0 -> LEGACY at-fire damage (~0.35s before the visual contact)"
+ : $" Cone (SpecialSlam): contact {coneContact}t ({coneContact / 60f:F2}s after fire; visual estimate ~21t)");
+ if (coneContact > PlayerAimSystem.CastFacingTicks)
+ sb.AppendLine($" !! cone contact {coneContact}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (a resting gamepad stick resolves a MOVE-facing slam)");
Debug.Log(sb.ToString());
return sb.ToString();
}
diff --git a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs
index dfc27be4a..4d979f870 100644
--- a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs
+++ b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs
@@ -71,6 +71,8 @@ namespace ProjectM.Server
sockets.Add(new AbilitySocket { SparkId = f3 });
if (SystemAPI.HasComponent(player))
SystemAPI.SetComponent(player, default(SocketCooldown)); // 0 = ready: the swapped kit fires now
+ if (SystemAPI.HasComponent(player))
+ SystemAPI.SetComponent(player, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam (parity with the dev SetClass op)
if (haveDb && SystemAPI.HasComponent(player) && SystemAPI.HasComponent(player))
{
byte charId = SystemAPI.GetComponent(player).Id;
diff --git a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs
index 5a414f966..4497d6b6e 100644
--- a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs
+++ b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs
@@ -26,7 +26,6 @@ namespace ProjectM.Server
[UpdateBefore(typeof(EnemyAISystem))]
public partial struct ZonePulseSystem : ISystem
{
- const uint k_PulsePeriodTicks = 30u; // damage cadence (~0.5s at 60 ticks/sec)
const float k_VortexPullSpeed = 4f; // gentle inward drift for a Vortex (world units/sec)
const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre
@@ -49,7 +48,7 @@ namespace ProjectM.Server
if (!serverTick.IsValid) return;
uint now = serverTick.TickIndexForValidTick;
uint stamp = TickUtil.NonZero(now);
- uint reschedule = TickUtil.NonZero(now + k_PulsePeriodTicks);
+ uint reschedule = TickUtil.NonZero(now + ZoneEffect.PulsePeriodTicks);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
index d73c71a4b..af219d96b 100644
--- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
+++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
@@ -173,6 +173,8 @@ namespace ProjectM.Server
swSockets.Add(new AbilitySocket { SparkId = sf3 });
if (SystemAPI.HasComponent(sender))
SystemAPI.SetComponent(sender, default(SocketCooldown));
+ if (SystemAPI.HasComponent(sender))
+ SystemAPI.SetComponent(sender, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam
if (SystemAPI.HasComponent(sender) && SystemAPI.HasComponent(sender)
&& SystemAPI.TryGetSingleton(out var abilityDb2))
{
diff --git a/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs
new file mode 100644
index 000000000..ba0d9e1a3
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs
@@ -0,0 +1,22 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ /// 07-21 G6 (socket honesty, design review wf_98bf1268): SERVER-ONLY scheduled Cone-socket cleave —
+ /// the schedule-and-consume idiom applied to the SpecialSlam (Cone archetype)
+ /// socket, so its damage lands at the animation's visual contact instead of the fire tick. Stamped by
+ /// AbilityFireSystem when a Cone socket fires (ResolveTick = fire + TuningConfig.ConeContactTicks; knob 0 =
+ /// legacy immediate, no stamp), fired on a wrap-safe elapsed compare from LIVE state (cast-turn steers the
+ /// cone until contact; stats re-folded at resolve), CONSUMED by zeroing. A still-armed pending is FLUSHED
+ /// (fired early) before a recast overwrites it. Cleared on death (PlayerDeathStateSystem — an armed pending
+ /// must never fire from the respawn position) and by the dev SetClass swap (socket identity changes).
+ /// At resolve the socket is re-validated (bounds + still a Cone Spark) — consume-drop on mismatch.
+ /// Not replicated; client contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed.
+ public struct ConeContactPending : IComponentData
+ {
+ /// NonZero tick the armed cone's slam lands; 0 = nothing pending.
+ public uint ResolveTick;
+ /// Socket index (0..SocketId.Count-1) of the ARMED cast — stats/aim re-read live at resolve.
+ public byte Socket;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta
new file mode 100644
index 000000000..002704aa4
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8e1f7f8852ab4e548babedafccbc6084
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs
index a3d6d7375..bb5c26c9a 100644
--- a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs
+++ b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs
@@ -1,32 +1,42 @@
using Unity.Entities;
+using Unity.NetCode;
namespace ProjectM.Simulation
{
///
- /// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone
- /// archetype. NOT a [GhostField]: the zone is a server-spawned INTERPOLATED ownerless ghost whose
- /// position replicates via the stock LocalTransform variant; only its damage schedule lives here, server-side.
- /// ZonePulseSystem borrows the GeyserEruptSystem skeleton (the INVERTED invalid-tick guard so a
- /// born-0 tick never storm-fires + the = now + period reschedule) but is ENEMY-ONLY (no friendly fire),
- /// stamps = (kills credit the caster,
- /// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym).
- /// DestroyEntity when elapses.
+ /// Periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone archetype.
+ /// The zone is a server-spawned INTERPOLATED ownerless ghost; position replicates via the stock LocalTransform
+ /// variant. 07-21 G6 (design review wf_98bf1268): THREE fields are now [GhostField] so the client can
+ /// draw an HONEST fill telegraph (ZoneTelegraphSystem): (G3 ownership tint),
+ /// (rim = the TRUE folded damage radius) and (fill arrival = the
+ /// damage moment — server mutations propagate to all clients on an ownerless ghost). Damage schedule/amount
+ /// stay server-only. ZonePulseSystem borrows the GeyserEruptSystem skeleton (the INVERTED
+ /// invalid-tick guard so a born-0 tick never storm-fires + the = now + period reschedule) but is
+ /// ENEMY-ONLY (no friendly fire), stamps =
+ /// (kills credit the caster, feeding KillRewardSystem — NOT the -1 environment convention), and has NO region
+ /// gate (a no-world gym). DestroyEntity when elapses.
///
public struct ZoneEffect : IComponentData
{
- /// NetworkId of the casting player (damage/kill attribution).
- public int CasterNetworkId;
+ /// Pulse cadence in ticks — single source for ZonePulseSystem (damage) and ZoneTelegraphSystem
+ /// (the client fill window). Hoisted 07-21 G6 (was ZonePulseSystem.k_PulsePeriodTicks).
+ public const uint PulsePeriodTicks = 30;
- /// Planar (XZ) damage radius, world units.
- public float Radius;
+ /// NetworkId of the casting player (damage/kill attribution + the client's mine-vs-ally tint).
+ [GhostField] public int CasterNetworkId;
- /// Damage dealt to each living enemy in radius per pulse.
+ /// Planar (XZ) damage radius, world units. Replicated so the drawn rim is the TRUE folded radius.
+ [GhostField(Quantization = 100)] public float Radius;
+
+ /// Raw next-pulse tick (NonZero). 0 = unscheduled → lazy-stamped born-correct (GeyserErupt H2
+ /// rule, never storm-fires). Replicated for the client fill: ride the ABSOLUTE tick + a value-latch + a
+ /// was-counting-down arm-guard (the Geyser ★ rule — never edge-detect the re-stamp).
+ [GhostField] public uint NextTick;
+
+ /// Damage dealt to each living enemy in radius per pulse. Server-only.
public float DamagePerPulse;
- /// Raw next-pulse tick (NonZero). 0 = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires).
- public uint NextTick;
-
- /// Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick).
+ /// Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick). Server-only.
public uint ExpireTick;
/// See . bit0 = Vortex (also pull enemies toward the zone center each pulse).
diff --git a/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs b/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs
index 85bf9fe18..c8973b367 100644
--- a/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs
+++ b/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs
@@ -35,12 +35,22 @@ namespace ProjectM.Simulation
public static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks)
{
if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false;
- uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
+ uint startRaw = FireStartRaw(nextFireRaw, cooldownTicks);
var start = new NetworkTick(startRaw);
var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
+ /// Reconstructed fire-window START (raw NonZero tick) from the replicated per-socket cooldown
+ /// stamp minus the locally-derived CooldownTicks; 0 = no window (unstamped / degenerate inputs). The ONE
+ /// home of this reconstruction (07-21 G6 review wf_98bf1268: the cone connect-cue latches
+ /// contact = FireStartRaw + ConeContactTicks at the fire edge — never re-derive it inline).
+ public static uint FireStartRaw(uint nextFireRaw, int cooldownTicks)
+ {
+ if (nextFireRaw == 0u || cooldownTicks <= 0) return 0u;
+ return TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
+ }
+
/// LANTERN 4-socket fire/cone resolution (any-socket model): firing if ANY socketed,
/// NON-Movement Spark's per-socket window is mid-fire; cone if any such active socket holds a
/// Cone-archetype Spark. Movement sockets are skipped OUTRIGHT (blink = dodge, not cast). Without a
diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs
index b930a61a5..093ed45f0 100644
--- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs
+++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs
@@ -53,6 +53,10 @@ namespace ProjectM.Simulation
public float MeleeBufferTicks;
/// Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave).
public float MeleeContactTicks;
+ /// 07-21 G6 (socket honesty): ticks from the Cone-socket fire tick to its damage landing (the
+ /// SpecialSlam visual contact). 0 = IMMEDIATE (legacy at-fire cleave). Keep < CastFacingTicks (26) or a
+ /// resting gamepad stick resolves a movement-facing cone; default 21 sits 1 tick under WarriorCone cooldown 22.
+ public float ConeContactTicks;
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
@@ -96,6 +100,7 @@ namespace ProjectM.Simulation
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
MeleeBufferTicks = 10f, // 07-21 HEAVY LOCK: wider buffer fits the slower cadence (0 = off)
MeleeContactTicks = 20f, // 07-21 HEAVY LOCK: blade lands 20/12/25t per step -- clip speeds retuned to MATCH (wire tool 1.0/0.85/0.88; audit must show zero drift). Step-3 (25t) sits 1 tick under CastFacingTicks 26 -- do NOT raise without raising the cast window.
+ ConeContactTicks = 21f, // 07-21 G6: SpecialSlam contact ~0.35s after fire; keep < CastFacingTicks 26
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
@@ -130,6 +135,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeFinisherRangeMult:
case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10)
case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave)
+ case TuningKnob.ConeContactTicks: // 07-21 G6: 0 = IMMEDIATE sentinel (legacy at-fire cone)
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
@@ -172,6 +178,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break;
case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break;
case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = value; break;
+ case TuningKnob.ConeContactTicks: c.ConeContactTicks = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
@@ -210,6 +217,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks;
case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult;
case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks;
+ case TuningKnob.ConeContactTicks: return c.ConeContactTicks;
default: return 0f;
}
@@ -246,6 +254,7 @@ namespace ProjectM.Simulation
MeleeBufferTicks = c.MeleeBufferTicks,
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
MeleeContactTicks = c.MeleeContactTicks,
+ ConeContactTicks = c.ConeContactTicks,
};
@@ -280,6 +289,7 @@ namespace ProjectM.Simulation
MeleeBufferTicks = r.MeleeBufferTicks,
MeleeFinisherRangeMult = r.MeleeFinisherRangeMult,
MeleeContactTicks = r.MeleeContactTicks,
+ ConeContactTicks = r.ConeContactTicks,
};
}
@@ -318,9 +328,10 @@ namespace ProjectM.Simulation
public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel)
public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2
public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel)
+ public const byte ConeContactTicks = 32; // 07-21 G6 (0 = immediate sentinel)
/// Knob count (overlay iteration bound).
- public const byte Count = 32;
+ public const byte Count = 33;
}
///
@@ -359,6 +370,7 @@ namespace ProjectM.Simulation
public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers)
public float MeleeFinisherRangeMult;
public float MeleeContactTicks;
+ public float ConeContactTicks; // 07-21 G6 socket honesty (dev-protocol bump: rebuild both peers)
}
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs
index 4aea70d22..aac2a8942 100644
--- a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs
@@ -45,6 +45,14 @@ namespace ProjectM.Simulation
// MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn.
if (SystemAPI.HasComponent(entity))
SystemAPI.SetComponent(entity, default(MeleeCombo));
+ // 07-21 G6 (review wf_98bf1268): zero any ARMED scheduled cleave — a pending surviving death
+ // fires from the RESPAWN position once Dead re-disables (the elapsed compare passes instantly).
+ // Fixes the shipped melee variant + the new cone in one stroke. Idempotent default-writes;
+ // client copies are always zero, so the both-world predicted write is rollback-safe.
+ if (SystemAPI.HasComponent(entity))
+ SystemAPI.SetComponent(entity, default(MeleeCleavePending));
+ if (SystemAPI.HasComponent(entity))
+ SystemAPI.SetComponent(entity, default(ConeContactPending));
if (SystemAPI.HasComponent(entity))
{
var cc = SystemAPI.GetComponent(entity);
diff --git a/Assets/_Project/Tests/EditMode/SaturationMathTests.cs b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs
new file mode 100644
index 000000000..d21175ff3
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs
@@ -0,0 +1,45 @@
+using NUnit.Framework;
+using ProjectM.Client;
+
+namespace ProjectM.Tests
+{
+ /// 07-21 G4 — pins the ally-FX saturation ramp (SaturationMath.AllyScale): full loudness at or
+ /// under the start count, linear to the floor at the full count, clamped floor, degenerate-range snap.
+ /// Enemy telegraphs never ride this scale (structural, no knob) — that is pinned by ARCHITECTURE, not here.
+ public class SaturationMathTests
+ {
+ [Test]
+ public void At_Or_Below_Start_Is_Full()
+ {
+ Assert.AreEqual(1f, SaturationMath.AllyScale(0, 8, 16, 0.35f), 1e-4f);
+ Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 16, 0.35f), 1e-4f);
+ }
+
+ [Test]
+ public void Midpoint_Is_Half_Lerped()
+ {
+ Assert.AreEqual(0.675f, SaturationMath.AllyScale(12, 8, 16, 0.35f), 1e-4f); // lerp(1, .35, .5)
+ }
+
+ [Test]
+ public void At_Or_Past_Full_Sits_At_Floor()
+ {
+ Assert.AreEqual(0.35f, SaturationMath.AllyScale(16, 8, 16, 0.35f), 1e-4f);
+ Assert.AreEqual(0.35f, SaturationMath.AllyScale(40, 8, 16, 0.35f), 1e-4f);
+ }
+
+ [Test]
+ public void Degenerate_Range_Snaps_To_Floor_Past_Start()
+ {
+ Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 8, 0.5f), 1e-4f);
+ Assert.AreEqual(0.5f, SaturationMath.AllyScale(9, 8, 8, 0.5f), 1e-4f);
+ }
+
+ [Test]
+ public void Floor_Is_Clamped_01()
+ {
+ Assert.AreEqual(1f, SaturationMath.AllyScale(100, 8, 16, 1.7f), 1e-4f);
+ Assert.AreEqual(0f, SaturationMath.AllyScale(100, 8, 16, -2f), 1e-4f);
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta
new file mode 100644
index 000000000..72237aa9a
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 876bdcb19d45464418a4a634ebc134c8
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs b/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs
index 2becbe597..e83efe31e 100644
--- a/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs
+++ b/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs
@@ -152,5 +152,27 @@ namespace ProjectM.Tests
Assert.IsTrue(firing);
Assert.IsTrue(cone);
}
+
+ // ---- 07-21 G6 (review wf_98bf1268): FireStartRaw — the ONE home of the window-start reconstruction ----
+
+ [Test]
+ public void FireStartRaw_Reconstructs_The_Fire_Tick()
+ {
+ Assert.AreEqual(978u, TickWindowMath.FireStartRaw(1000u, 22));
+ }
+
+ [Test]
+ public void FireStartRaw_Degenerate_Inputs_Return_Zero()
+ {
+ Assert.AreEqual(0u, TickWindowMath.FireStartRaw(0u, 22), "unstamped cooldown row");
+ Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, 0), "no cooldown length");
+ Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, -5), "negative cooldown length");
+ }
+
+ [Test]
+ public void FireStartRaw_Wrap_To_Zero_Coerces_Through_NonZero()
+ {
+ Assert.AreEqual(TickUtil.NonZero(0u), TickWindowMath.FireStartRaw(10u, 10), "the 0-sentinel is never returned for a REAL window start");
+ }
}
}
\ No newline at end of file