Base<->Expedition ties: portal rooms, class-at-base, prep spend + Health.Max, C3/C4/Spitter (DR-046)

Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes
persist past the last kill; PortalInteractRequest -> server-only PortalCommand
advances (client derives the portal pos). RunDirectorSystem moves teardown off
the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit,
and advances an empty expedition immediately (incl. a boss clear).

Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class
MetaTierState replay, so the base RPC and the dev SetClass can't drift);
ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest,
PrepCatalog): once-per-run == the row's prep StatModifier band is present,
TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band.

Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss +
floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3),
hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold.

456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded;
post-impl clean bar the RoomExplore boss-clear dead-time, fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:37:24 -07:00
parent 35357faa7a
commit 304ee8c2a7
35 changed files with 860 additions and 110 deletions
+4 -4
View File
@@ -873,10 +873,10 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 28 MaxHealth: 28
HitRadius: 1 HitRadius: 1
MoveSpeed: 3 MoveSpeed: 2.4
AttackRange: 1.8 AttackRange: 1.8
AttackDamage: 8 AttackDamage: 8
AttackCooldownTicks: 66 AttackCooldownTicks: 90
--- !u!95 &13761174629013833 --- !u!95 &13761174629013833
Animator: Animator:
serializedVersion: 7 serializedVersion: 7
@@ -938,8 +938,8 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring
PreferredRange: 9 PreferredRange: 9
RangeTolerance: 1.5 RangeTolerance: 1.5
ProjectileSpeed: 11 ProjectileSpeed: 8
CorneredRange: 3 CorneredRange: 6
WindupTicks: 26 WindupTicks: 26
--- !u!1 &3924377442331254583 --- !u!1 &3924377442331254583
GameObject: GameObject:
@@ -63,6 +63,7 @@ namespace ProjectM.Client
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes) int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
bool _coneTickInit; bool _coneTickInit;
Material _dangerMat; Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new(); readonly Dictionary<Entity, GameObject> _dangerZones = new();
@@ -249,6 +250,8 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); 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)
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
if (isEnemy) if (isEnemy)
@@ -304,6 +307,8 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -413,7 +418,7 @@ namespace ProjectM.Client
if (finisher) if (finisher)
{ {
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
if (FeelConfig.HitStopFreezeEnabled) PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit) TryHold(); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
} }
} }
_lastLocalSwingTick = mc.SwingStartTick; _lastLocalSwingTick = mc.SwingStartTick;
@@ -445,9 +450,29 @@ namespace ProjectM.Client
} }
float coneRange = Mathf.Max(0.1f, ceff.Range); float coneRange = Mathf.Max(0.1f, ceff.Range);
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f); float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false); // C3: client cone-overlap over the cached enemy snapshot -> an immediate "you hit" read + thunk
// (the server-only cone damage arrives a few ticks later), mirroring the melee connect path.
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
float coneCos = Mathf.Cos(coneHalf);
float2 cfdir = new float2(cface.x, cface.z);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, cfdir, 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, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, coneConnected);
PlayClip(_swingClip, (Vector3)localPos, 0.5f); PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f); PrototypeCameraRig.AddShake(0.06f);
if (coneConnected)
{
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);
}
} }
} }
_lastConeFireTick = nextFire; _lastConeFireTick = nextFire;
@@ -813,7 +838,18 @@ namespace ProjectM.Client
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a // the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash. // directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected) // C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold()
{
if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now;
PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames);
}
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
{ {
if (_slashMr == null || _slashMat == null) return; if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen; bool finisher = step >= comboLen;
@@ -3,6 +3,8 @@ using ProjectM.Simulation;
using Unity.Entities; using Unity.Entities;
using Unity.NetCode; using Unity.NetCode;
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file) using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
using Unity.Mathematics; // DR-046: portal proximity math (float3/.xz/math.distance)
using UnityEngine; using UnityEngine;
using UnityEngine.UIElements; using UnityEngine.UIElements;
@@ -341,26 +343,24 @@ namespace ProjectM.Client
// Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe) // Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe)
// in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region // in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region
// siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the // siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so
// baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change). // the fraction reads true directly.
bool bossAlive = false; bool bossAlive = false;
float bossHp = 0f, bossMax = 0f; float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{ {
float bossBakedMax = 0f;
foreach (var (bhq, tele, blt) in foreach (var (bhq, tele, blt) in
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>()) SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{ {
if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers
if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy) if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy)
if (bhq.ValueRO.Max > bossBakedMax) if (bhq.ValueRO.Max > bossMax)
{ {
bossBakedMax = bhq.ValueRO.Max; bossMax = bhq.ValueRO.Max;
bossHp = bhq.ValueRO.Current; bossHp = bhq.ValueRO.Current;
bossAlive = bhq.ValueRO.Current > 0f; bossAlive = bhq.ValueRO.Current > 0f;
} }
} }
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
} }
UpdateBossBar(bossAlive, bossHp, bossMax); UpdateBossBar(bossAlive, bossHp, bossMax);
@@ -438,6 +438,10 @@ namespace ProjectM.Client
metaShow = true; metaShow = true;
} }
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord); UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging)
UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging)
UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore)
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const). // DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
// (Step 11: upgrade-button affordability tint retired with the button.) // (Step 11: upgrade-button affordability tint retired with the button.)
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one // EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
@@ -1834,7 +1838,150 @@ namespace ProjectM.Client
_depthPanel.style.display = DisplayStyle.Flex; _depthPanel.style.display = DisplayStyle.Flex;
} }
void UpdateMetaShop(bool show, byte classId, int aether, // DR-046: base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore).
VisualElement _classPanel, _prepPanel, _prepRowsHost;
Label _classTitle, _prepTitle, _portalPrompt;
Button _classWarBtn, _classRangerBtn;
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
int _classShownFor, _prepShownFor;
void UpdateClassPanel(bool show, byte classId)
{
if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; }
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; }
int sig = classId + 1;
if (_classShownFor != sig)
{
bool ranger = classId == ClassTraits.RangerClass;
_classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓";
_classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER";
_classWarBtn.SetEnabled(ranger);
_classRangerBtn.SetEnabled(!ranger);
_classShownFor = sig;
}
_classPanel.style.display = DisplayStyle.Flex;
}
void BuildClassPanel(VisualElement root)
{
_classPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_classPanel.style.position = Position.Absolute;
_classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22);
_classPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
MenuUi.Round(box, 10);
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
_classTitle = new Label("CLASS");
_classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14;
_classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8;
box.Add(_classTitle);
_classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass));
_classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn);
_classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass));
box.Add(_classRangerBtn);
_classPanel.Add(box); root.Add(_classPanel);
}
void UpdatePrepPanel(bool show, int ore, int bio, int aether)
{
if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; }
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; }
uint boughtMask = 0;
foreach (var mods in SystemAPI.Query<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
for (int m = 0; m < mods.Length; m++)
{
uint sid = mods[m].SourceId;
if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan)
boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase));
}
break;
}
int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101;
if (sig == 0) sig = 1;
if (_prepShownFor != sig)
{
_prepRowsHost.Clear();
for (int i = 0; i < PrepCatalog.Count; i++)
{
var r = PrepCatalog.Rows[i];
int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore;
bool bought = (boughtMask & (uint)(1 << r.Id)) != 0;
string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore";
string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName);
byte buyId = r.Id;
var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId));
row.style.width = 240; row.style.marginBottom = 4;
row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft;
row.SetEnabled(!bought && have >= r.Cost);
_prepRowsHost.Add(row);
}
_prepShownFor = sig;
}
_prepPanel.style.display = DisplayStyle.Flex;
}
static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed"
: id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage";
void BuildPrepPanel(VisualElement root)
{
_prepPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_prepPanel.style.position = Position.Absolute;
_prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45);
_prepPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
MenuUi.Round(box, 10);
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
_prepTitle = new Label("PREP LOADOUT (lasts the run)");
_prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14;
_prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8;
box.Add(_prepTitle);
_prepRowsHost = new VisualElement(); box.Add(_prepRowsHost);
_prepPanel.Add(box); root.Add(_prepPanel);
}
void UpdatePortalPrompt(RunInfo runInfo, bool haveRun)
{
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
bool show = false;
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
float3 center = BaseGridMath.PlotCenter(anchor);
float3 portalPos = RegionMath.ExpeditionRoomOrigin(center, (byte)(runInfo.CurrentRoom & 1));
portalPos.z += Tuning.PortalOffsetZ;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
if (math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange)
{
show = true;
var kb = UnityEngine.InputSystem.Keyboard.current;
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
}
break;
}
}
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
}
void BuildPortalPrompt(VisualElement root)
{
_portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter);
_portalPrompt.style.position = Position.Absolute;
_portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240;
_portalPrompt.pickingMode = PickingMode.Ignore;
_portalPrompt.style.display = DisplayStyle.None;
root.Add(_portalPrompt);
}
void UpdateMetaShop(bool show, byte classId, int aether,
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record) BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
{ {
if (!show) if (!show)
@@ -0,0 +1,36 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into
/// <see cref="ClassSelectRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the class id; the server
/// re-validates the phase + applies the full swap. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ClassSelectSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this.</summary>
public static void RequestClass(byte classId) => s_Queue.Enqueue(classId);
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(ClassSelectRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new ClassSelectRequest { ClassId = s_Queue.Dequeue() });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0d67e293cdeb454fbef8414d4aeb813
@@ -0,0 +1,33 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single
/// <see cref="PortalInteractRequest"/> RPC. Coalesced (one per drain — repeat E while the server is still in
/// RoomExplore is harmless, the server latches once). Statics reset on play-enter.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class PortalInteractSendSystem : SystemBase
{
static bool s_pending;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_pending = false;
/// <summary>Request leaving via the portal (the HUD prompt / E-key near the portal drives this).</summary>
public static void Interact() => s_pending = true;
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
if (!s_pending) return;
s_pending = false;
EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eac0f1b2340c0b344b430363311d3be5
@@ -0,0 +1,36 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into
/// <see cref="PrepPurchaseRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server
/// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class PrepPurchaseSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this.</summary>
public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId);
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(PrepPurchaseRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new PrepPurchaseRequest { OptionId = s_Queue.Dequeue() });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b60ac8ed7ee3ce4081c55b2188be142
@@ -0,0 +1,82 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their class at base. Honored ONLY in
/// Staging (class = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
/// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + <see cref="ClassSwapUtil.HealClamp"/>.
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. NOT
/// Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC — Burst safety over micro-perf).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct ClassSelectReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded).
Entity dir = Entity.Null;
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ClassSelectRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!SystemAPI.HasComponent<NetworkId>(conn)
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
continue;
if (!SystemAPI.HasComponent<AbilityRef>(player)) continue;
var mods = SystemAPI.GetBuffer<StatModifier>(player);
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord,
out byte newClass, out byte newAbilityId);
SystemAPI.SetComponent(player, new AbilityRef { Id = newAbilityId });
if (SystemAPI.HasComponent<PlayerClass>(player))
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
if (SystemAPI.HasComponent<AbilityCooldown>(player))
SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now
if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player))
{
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
if (abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
{
var hp = SystemAPI.GetComponent<Health>(player);
ClassSwapUtil.HealClamp(ref hp, baseChar.MaxHealth, mods);
SystemAPI.SetComponent(player, hp);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01a67c1a54ce0574b86700e32e0bdb5b
@@ -397,7 +397,11 @@ namespace ProjectM.Server
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target. // 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
var sp = spitter.ValueRO; var sp = spitter.ValueRO;
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance); // Once the player has closed inside CorneredRange the Spitter STANDS (no flee) + point-blanks — so a
// melee player who commits can actually catch it (fixes the endless-kite complaint; the spit is dash-dodgeable).
bool sCorneredMove = math.distance(pos.xz, sTargetPos.xz) <= sp.CorneredRange;
float3 bandVel = sCorneredMove ? float3.zero
: EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y; float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter); if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = sNewPos; xform.ValueRW.Position = sNewPos;
@@ -0,0 +1,79 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="PrepPurchaseRequest"/> — the base PREP-LOADOUT spend (DR-046). Modeled on
/// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity
/// idiom — <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/>, since Withdraw
/// CLAMPS and never rejects). A purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep band
/// (<see cref="Tuning.PrepSourceIdBase"/> + option id) on the BUYER only (prep is personal). "Once per run" needs
/// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so
/// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem;
/// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct PrepPurchaseSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PrepPurchaseRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<ResourceLedger>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<PrepPurchaseRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!SystemAPI.HasComponent<NetworkId>(conn)
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
continue;
if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop
uint sourceId = Tuning.PrepSourceIdBase + row.Id;
var mods = SystemAPI.GetBuffer<StatModifier>(buyer);
bool already = false;
for (int m = 0; m < mods.Length; m++)
if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning)
if (already) continue;
// LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass).
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS
StorageMath.Withdraw(ledger, row.CostResId, row.Cost);
mods.Add(new StatModifier
{
Target = row.Target,
Op = row.Op,
Value = row.Value,
SourceId = sourceId,
});
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f052eb701594a3a42ba83e524dd2d28b
@@ -182,60 +182,29 @@ namespace ProjectM.Server
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender) if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
&& SystemAPI.HasBuffer<StatModifier>(sender)) && SystemAPI.HasBuffer<StatModifier>(sender))
{ {
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
var classMods = SystemAPI.GetBuffer<StatModifier>(sender); var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
ClassTraits.Reapply(newClass, classMods); Entity dir2 = Entity.Null;
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) }); bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
// Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
// sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta // DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil,
// rows would survive — strip the meta band, replay the NEW class's persisted tiers (the // used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
// GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
// MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the out byte swNewClass, out byte swNewAbility);
// refill folds the new class's meta MaxHealth too. SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
TimedModifierUtil.RemoveBySourceIdRange(classMods, Tuning.MetaSourceIdBase,
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
if (SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out var metaRecord, true))
{
ref var metaPool = ref metaCat.Value.Value;
byte metaBit = BoonMath.MaskFor(newClass);
for (int mi = 0; mi < metaRecord.Length; mi++)
{
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
if (defIdx < 0) continue;
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
classMods.Add(new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
});
}
}
if (SystemAPI.HasComponent<PlayerClass>(sender)) if (SystemAPI.HasComponent<PlayerClass>(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass }); SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
if (SystemAPI.HasComponent<AbilityCooldown>(sender)) if (SystemAPI.HasComponent<AbilityCooldown>(sender))
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
// Heal a living player to the new class's full max (fold blob base + the just-reseeded
// buffer, like StatRecomputeSystem; Effective* still lags a tick here). Doubles as the
// down-clamp when the new max is lower (nothing else clamps Current off a damage event).
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender) if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb)) && SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
{ {
var hp = SystemAPI.GetComponent<Health>(sender); byte charId2 = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id; if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
{ {
hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods); var hp2 = SystemAPI.GetComponent<Health>(sender);
SystemAPI.SetComponent(sender, hp); ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp2);
} }
} }
} }
@@ -69,6 +69,8 @@ namespace ProjectM.Server
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge. // launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u }); ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
ecb.AddComponent(director, default(RouteCommand)); ecb.AddComponent(director, default(RouteCommand));
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
ecb.AddComponent(director, default(MetaCounters)); ecb.AddComponent(director, default(MetaCounters));
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
@@ -0,0 +1,66 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="PortalInteractRequest"/> — a participant interacting the room-exit portal during
/// RoomExplore. Honored ONLY when <c>RunInfo.Lifecycle==RoomExplore</c> and the sender is an EXPEDITION player
/// (region gate, the RouteSelect idiom). Sets the server-only <see cref="PortalCommand"/> latch IN-PLACE; it does
/// NOT write RunInfo or tear the room down — RunDirectorSystem (the sole FSM/teardown owner) consumes the latch and
/// advances. Plain server group, before RunDirectorSystem; requests ALWAYS destroyed; NO CyclePhase edge.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct PortalInteractReceiveSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PortalInteractRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<PortalCommand>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
bool gateOpen = SystemAPI.GetComponent<RunInfo>(dirEntity).Lifecycle == RunLifecycle.RoomExplore;
// Sender region lookup (N3 idiom): a base-bound joiner cannot pull the party out of the room.
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
foreach (var (owner, region) in
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
bool interacted = SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<PortalInteractRequest>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
bool valid = gateOpen && !interacted
&& SystemAPI.HasComponent<NetworkId>(conn)
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
&& senderRegion == RegionId.Expedition;
if (valid)
{
SystemAPI.SetComponent(dirEntity, new PortalCommand { HasInteract = 1 });
interacted = true;
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
regionByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cfb4147e08bf1b244bef1fa5d71d8b9e
@@ -173,10 +173,8 @@ namespace ProjectM.Server
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity) if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared) && SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
{ {
var ecb = new EntityCommandBuffer(Allocator.Temp); // DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF)); // RoomReward + the loot window so the party can mine after clearing.
ecb.Playback(state.EntityManager);
ecb.Dispose();
run.RoomsClearedThisRun += 1; run.RoomsClearedThisRun += 1;
if (info.CurrentRoom >= info.RoomCount - 1) if (info.CurrentRoom >= info.RoomCount - 1)
@@ -215,23 +213,55 @@ namespace ProjectM.Server
// HUD modal open or stall a later reward gate (post-impl review, confirmed major). // HUD modal open or stall a later reward gate (post-impl review, confirmed major).
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>()) foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
offer.ValueRW = default; offer.ValueRW = default;
// DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist
// (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout.
run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks);
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window
info.Lifecycle = RunLifecycle.RoomExplore;
break;
}
case RunLifecycle.RoomExplore:
{
// DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a
// participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft
// timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell).
if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win
{ // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment.
run.ExploreGraceTick = 0u;
info.Lifecycle = RunLifecycle.Returning;
break;
}
bool portalUsed = SystemAPI.HasComponent<PortalCommand>(dirEntity)
&& SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
bool exploreTimedOut = run.ExploreGraceTick == 0u
|| !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick);
if (!portalUsed && !exploreTimedOut)
break; // still looting
run.ExploreGraceTick = 0u;
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(PortalCommand));
// The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance.
var exploreEcb = new EntityCommandBuffer(Allocator.Temp);
RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF));
exploreEcb.Playback(state.EntityManager);
exploreEcb.Dispose();
if (run.LastTerminalCleared != 0) if (run.LastTerminalCleared != 0)
{ {
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
} }
else else
{ {
// Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable // Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
// options (the client map panel is regen-for-display; the clickable buttons bind to these // options; RouteSelect is the teardown gap (the room is gone now).
// bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room
// materializes only when the choice commits.
var map = RunMapMath.Generate(run.RunSeed); var map = RunMapMath.Generate(run.RunSeed);
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
out var cols);
if (optionCount == 0) if (optionCount == 0)
{ {
// Unreachable by construction (every non-terminal node has an out-edge) — a future
// generator regression must abort CLEANLY, never wedge on stale options (review F4).
info.RouteOptionCount = 0; info.RouteOptionCount = 0;
info.Lifecycle = RunLifecycle.Returning; info.Lifecycle = RunLifecycle.Returning;
} }
@@ -246,8 +276,6 @@ namespace ProjectM.Server
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0; info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0; info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks); run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
// Entry-clear: any accepted pick provably belongs to THIS gate (RouteSelectSystem runs
// BEFORE this system, so it cannot accept on the entry tick).
if (SystemAPI.HasComponent<RouteCommand>(dirEntity)) if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(RouteCommand)); SystemAPI.SetComponent(dirEntity, default(RouteCommand));
info.Lifecycle = RunLifecycle.RouteSelect; info.Lifecycle = RunLifecycle.RouteSelect;
@@ -256,7 +284,8 @@ namespace ProjectM.Server
break; break;
} }
case RunLifecycle.RouteSelect:
case RunLifecycle.RouteSelect:
{ {
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick // Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players); // from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
@@ -365,6 +394,9 @@ namespace ProjectM.Server
{ {
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan); Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
offer.ValueRW = default; offer.ValueRW = default;
} }
@@ -0,0 +1,16 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server: pick the player's class at base. Server honors it ONLY in <c>RunInfo.Lifecycle==Staging</c>
/// and applies the FULL swap via <see cref="ClassSwapUtil"/> (class seeds + permanent-meta re-sync + AbilityRef +
/// cooldown reset + heal/clamp) — a partial swap would mis-set the meta record + Max HP (DR-046 review). The class
/// is server-authoritative + re-validated, so a forged/stale request is simply dropped. UNCONDITIONAL wire type.
/// </summary>
public struct ClassSelectRequest : IRpcCommand
{
/// <summary>Requested class id (0/unknown → Warrior via ClassTraits.Normalize).</summary>
public byte ClassId;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0fcc1501b0c2144585249be7b65dd61
@@ -0,0 +1,65 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// The ONE in-place class-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing
/// base ClassSelect (Staging). A class swap is much more than re-seeding: the pre-code review (DR-046) confirmed
/// that swapping only the class-seed band leaves the OLD class's PERMANENT META rows on the buffer and omits the
/// NEW class's — so a base swap would drain Aether into the wrong class's record and mis-set Max HP. This helper
/// mirrors the (previously editor-only) full swap: <see cref="ClassTraits.Reapply"/> (class-seed band) + the meta
/// band strip + per-class <see cref="MetaTierState"/> replay. The caller then writes AbilityRef/PlayerClass/
/// AbilityCooldown and calls <see cref="HealClamp"/> (a static can't resolve singletons or SystemAPI.SetComponent,
/// so the caller passes the resolved pieces). Server-authoritative + prediction-correct (StatRecomputeSystem
/// refolds EffectiveCharacterStats next tick).
/// </summary>
public static class ClassSwapUtil
{
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
/// <paramref name="mods"/>. Returns the normalized class + its Fire ability id (the caller sets AbilityRef).
/// <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the strip still runs).</summary>
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord,
out byte newClass, out byte newAbilityId)
{
newClass = ClassTraits.Normalize(rawClass);
ClassTraits.Reapply(newClass, mods);
newAbilityId = ClassTraits.AbilityFor(newClass);
// Strip the OLD class's meta rows (Reapply only touched the class-seed band), then replay the NEW class's
// persisted tiers (the GoInGame skip/clamp rules) so the permanent channel stays correct across the swap.
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.MetaSourceIdBase,
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
if (haveMeta && metaCat.Value.IsCreated && metaRecord.IsCreated)
{
ref var metaPool = ref metaCat.Value.Value;
byte metaBit = BoonMath.MaskFor(newClass);
for (int mi = 0; mi < metaRecord.Length; mi++)
{
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
if (defIdx < 0) continue;
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
mods.Add(new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
});
}
}
}
/// <summary>Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the
/// just-reseeded <paramref name="mods"/>, like StatRecomputeSystem). Doubles as the down-clamp when the new
/// class's max is lower (Warrior +30 HP vs Ranger -15%). No-op on a corpse (Current&lt;=0) — respawn refills.</summary>
public static void HealClamp(ref Health health, float baseMaxHealth, DynamicBuffer<StatModifier> mods)
{
if (health.Current > 0f)
health.Current = StatMath.Apply(baseMaxHealth, StatTarget.MaxHealth, mods);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 546e843270f89d04ca6d6cf816e217d3
@@ -14,7 +14,8 @@ namespace ProjectM.Simulation
/// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary> /// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary>
[GhostField] public float Current; [GhostField] public float Current;
/// <summary>Maximum hit points. Baked identically on client and server; not replicated.</summary> /// <summary>Maximum hit points. Replicated so a client HUD bar reads a correct fraction even when Max is
public float Max; /// modified server-side (boss x8; class/boon HP mods) — the boss bar + floating enemy HP bars depend on it (review: no player-bar surface reads Health.Max).</summary>
[GhostField] public float Max;
} }
} }
@@ -0,0 +1,42 @@
namespace ProjectM.Simulation
{
/// <summary>One base "prep loadout" option: spend a base resource before launch for a RUN-SCOPED stat buff
/// (stripped on the Returning edge like a boon). Mechanical fields only — the HUD supplies display labels.</summary>
public struct PrepRow
{
public byte Id;
public byte CostResId; // ResourceId.*
public int Cost;
public byte Target; // StatTarget
public byte Op; // ModOp
public float Value;
}
/// <summary>
/// The base PREP-LOADOUT catalog (DR-046): the player funds each run's power from base resources at Staging. A
/// purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep SourceId band
/// (<see cref="Tuning.PrepSourceIdBase"/> + Id), which <see cref="Server"/>'s PrepPurchaseSystem gates once-per-run
/// by that SourceId's PRESENCE (its lifetime == the band, stripped on Returning — so it re-buys next run for free,
/// no separate latch). A plain managed static table (read by the non-Burst receiver + the managed HUD).
/// </summary>
public static class PrepCatalog
{
public static readonly PrepRow[] Rows =
{
new PrepRow { Id = 0, CostResId = ResourceId.Ore, Cost = 30, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f },
new PrepRow { Id = 1, CostResId = ResourceId.Biomass, Cost = 40, Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.12f },
new PrepRow { Id = 2, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
new PrepRow { Id = 3, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
};
public static int Count => Rows.Length;
public static bool TryGet(byte id, out PrepRow row)
{
for (int i = 0; i < Rows.Length; i++)
if (Rows[i].Id == id) { row = Rows[i]; return true; }
row = default;
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef0c16b1e46d22c42bf38db14b2983b5
@@ -0,0 +1,16 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server: buy a base PREP-LOADOUT option (<see cref="PrepCatalog"/> id). Honored ONLY in Staging; the
/// server prices it from the catalog (never on the wire), does an in-loop <see cref="StorageMath.TotalOf"/>
/// pre-check BEFORE <see cref="StorageMath.Withdraw"/> (DR-014 atomicity), and appends the run-scoped
/// <see cref="StatModifier"/> once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type.
/// </summary>
public struct PrepPurchaseRequest : IRpcCommand
{
/// <summary>Prep-catalog option id.</summary>
public byte OptionId;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e945968f38977974f926709051f28609
@@ -39,6 +39,19 @@ namespace ProjectM.Simulation
public int ForLayer; public int ForLayer;
} }
/// <summary>
/// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem
/// sets <see cref="HasInteract"/> when a player interacts the portal during RoomExplore; RunDirectorSystem (the sole
/// RunInfo/RunRuntime writer) reads it to advance the run + tear the room down, then clears it. NOT replicated.
/// Added unconditionally at director spawn (like RouteCommand).
/// </summary>
public struct PortalCommand : IComponentData
{
/// <summary>1 once a participant has interacted the room-exit portal this RoomExplore.</summary>
public byte HasInteract;
}
/// <summary> /// <summary>
/// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for /// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for
/// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot /// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot
@@ -129,6 +129,19 @@ namespace ProjectM.Simulation
/// never floods past readable (also bounded by the director's MaxAlive count).</summary> /// never floods past readable (also bounded by the director's MaxAlive count).</summary>
public const int BossSummonLiveCap = 8; public const int BossSummonLiveCap = 8;
// ---- DR-046 room-exit PORTAL / loot window ----
/// <summary>Client-side portal position offset from the room origin (where the party landed): a short step
/// back toward the entrance, so 'leave the way you came'. Client VFX + proximity prompt only.</summary>
public const float PortalOffsetZ = -5f;
/// <summary>How close the local player must be to the portal to show 'E to LEAVE' + send the interact.</summary>
public const float PortalInteractRange = 3.5f;
/// <summary>RoomExplore soft-timeout (~30 s @60): auto-advance if nobody interacts the portal (no softlock).</summary>
public const uint ExploreGraceTicks = 1800;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
@@ -154,6 +167,14 @@ namespace ProjectM.Simulation
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary> /// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
public const uint BoonSourceIdSpan = 0x10000u; public const uint BoonSourceIdSpan = 0x10000u;
/// <summary>DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon
/// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live
/// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons.</summary>
public const uint PrepSourceIdBase = 0x00D00000u;
/// <summary>Width of the prep band (far above the tiny option count).</summary>
public const uint PrepSourceIdSpan = 0x10000u;
/// <summary>Base of the PERMANENT meta-upgrade SourceId band: a purchased tier's live StatModifier is /// <summary>Base of the PERMANENT meta-upgrade SourceId band: a purchased tier's live StatModifier is
/// keyed MetaSourceIdBase + UpgradeId (absolute-value UPSERT — one row per owned upgrade, set to /// keyed MetaSourceIdBase + UpgradeId (absolute-value UPSERT — one row per owned upgrade, set to
/// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn. /// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn.
@@ -0,0 +1,12 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server: interact with the room-exit portal to leave (advance the run). Client-gated on proximity +
/// the RoomExplore lifecycle (both replicated/derivable client-side); the server honors it ONLY in RoomExplore
/// from an expedition player, setting <see cref="PortalCommand"/> for RunDirectorSystem (the sole RunInfo writer)
/// to consume. Empty payload. UNCONDITIONAL wire type.
/// </summary>
public struct PortalInteractRequest : IRpcCommand { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d527637b76c7a545817f6f388533248
@@ -24,6 +24,11 @@ namespace ProjectM.Simulation
public const byte Returning = 4; public const byte Returning = 4;
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary> /// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
public const byte RouteSelect = 5; public const byte RouteSelect = 5;
/// <summary>DR-046: room cleared + boon picked, but the room + resource NODES persist and a portal is up.
/// Party loots; interacting the portal (or a soft-timeout) tears the room down + advances (RouteSelect, or
/// Returning if the boss fell). Append-only byte value — no ghost re-mean.</summary>
public const byte RoomExplore = 6;
} }
/// <summary> /// <summary>
@@ -48,6 +48,11 @@ namespace ProjectM.Simulation
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary> /// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
public uint RouteGraceTick; public uint RouteGraceTick;
/// <summary>DR-046: RoomExplore soft-timeout (NonZero; auto-advance if nobody interacts the portal), so the
/// loot window can never softlock. Set on entering RoomExplore, compared via NetworkTick.IsNewerThan.</summary>
public uint ExploreGraceTick;
// ---- boons ---- // ---- boons ----
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary> /// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
public uint BoonPickCounter; public uint BoonPickCounter;
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
map = RunMapMath.Generate(Seed); map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
em.SetComponentData(dir, new RunInfo em.SetComponentData(dir, new RunInfo
{ {
Lifecycle = RunLifecycle.InRoom, Lifecycle = RunLifecycle.InRoom,
@@ -73,7 +73,15 @@ namespace ProjectM.Tests
static void MarkCleared(EntityManager em, Entity dir) => static void MarkCleared(EntityManager em, Entity dir) =>
em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 }); em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 });
static int RoomEntities(EntityManager em) // DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick).
static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir)
{
em.SetComponentData(dir, new PortalCommand { HasInteract = 1 });
group.Update();
}
static int RoomEntities(EntityManager em)
{ {
var q = em.CreateEntityQuery(typeof(RoomTag)); var q = em.CreateEntityQuery(typeof(RoomTag));
int n = q.CalculateEntityCount(); int n = q.CalculateEntityCount();
@@ -82,48 +90,48 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump() public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
{ {
var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager; var em = world.EntityManager;
// Room-0 content that must die at the RoomReward entry.
var node0 = em.CreateEntity(typeof(RoomTag)); var node0 = em.CreateEntity(typeof(RoomTag));
em.SetComponentData(node0, new RoomTag { Room = 0 }); em.SetComponentData(node0, new RoomTag { Room = 0 });
MarkCleared(em, dir); MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward + teardown group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here)
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle); Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)"); Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter"); Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet) group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore");
PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate
var gateInfo = em.GetComponentData<RunInfo>(dir); var gateInfo = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)"); Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate");
Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)");
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published"); Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
// Commit the party's pick directly through the server-only latch (the RPC path is pinned in
// RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1.
byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1); byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1);
byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col; : pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column group.Update(); // RouteSelect -> InRoom room 1 at the PICKED column
var info = em.GetComponentData<RunInfo>(dir); var info = em.GetComponentData<RunInfo>(dir);
var run = em.GetComponentData<RunRuntime>(dir); var run = em.GetComponentData<RunRuntime>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
Assert.AreEqual(1, info.CurrentRoom); Assert.AreEqual(1, info.CurrentRoom);
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)"); Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column");
Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped"); Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped");
Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed"); Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published"); Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType); Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed"); Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance"); Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f, Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f, "party teleported (+1500)");
"party teleported onto the idle sub-slot (+1500)");
world.Dispose(); world.Dispose();
} }
@@ -134,7 +142,6 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out map0); var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
var em = world.EntityManager; var em = world.EntityManager;
int bossLayer = map0.LayerCount - 1; int bossLayer = map0.LayerCount - 1;
// Jump the state to the boss room.
var info0 = em.GetComponentData<RunInfo>(dir); var info0 = em.GetComponentData<RunInfo>(dir);
info0.CurrentRoom = bossLayer; info0.CurrentRoom = bossLayer;
em.SetComponentData(dir, info0); em.SetComponentData(dir, info0);
@@ -145,8 +152,9 @@ namespace ProjectM.Tests
em.SetComponentData(dir, run0); em.SetComponentData(dir, run0);
MarkCleared(em, dir); MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1) group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists)
group.Update(); // RoomReward -> Returning group.Update(); // RoomReward -> RoomExplore
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
group.Update(); // Returning: bank + teleport home -> Staging group.Update(); // Returning: bank + teleport home -> Staging
var info = em.GetComponentData<RunInfo>(dir); var info = em.GetComponentData<RunInfo>(dir);
@@ -162,7 +170,7 @@ namespace ProjectM.Tests
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested"); Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated"); Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch) group.Update();
group.Update(); group.Update();
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)"); Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted); Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
@@ -197,17 +205,17 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager; var em = world.EntityManager;
MarkCleared(em, dir); MarkCleared(em, dir);
group.Update(); // -> RoomReward (teardown) group.Update();
group.Update(); // -> RouteSelect (gate open, grace armed at T0) group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
var gate = em.GetComponentData<RunInfo>(dir); var gate = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle); Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
byte pickIdx = (byte)(gate.RouteOptionCount - 1); byte pickIdx = (byte)(gate.RouteOptionCount - 1);
byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col; byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col;
// A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence).
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000); // way past any grace SetTick(world, T0 + 100000);
group.Update(); group.Update();
var info = em.GetComponentData<RunInfo>(dir); var info = em.GetComponentData<RunInfo>(dir);
@@ -222,12 +230,13 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager; var em = world.EntityManager;
MarkCleared(em, dir); MarkCleared(em, dir);
group.Update(); // -> RoomReward group.Update();
group.Update(); // -> RouteSelect group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
var gate = em.GetComponentData<RunInfo>(dir); var gate = em.GetComponentData<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col; byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000); // grace elapses, nobody picked SetTick(world, T0 + 100000);
group.Update(); group.Update();
var info = em.GetComponentData<RunInfo>(dir); var info = em.GetComponentData<RunInfo>(dir);
@@ -242,8 +251,9 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager; var em = world.EntityManager;
MarkCleared(em, dir); MarkCleared(em, dir);
group.Update(); // -> RoomReward group.Update();
group.Update(); // -> RouteSelect group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0); Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
@@ -251,7 +261,7 @@ namespace ProjectM.Tests
var info = em.GetComponentData<RunInfo>(dir); var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle); Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle);
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)"); Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3)");
world.Dispose(); world.Dispose();
} }
} }