diff --git a/Assets/_Project/Prefabs/EnemySpitter.prefab b/Assets/_Project/Prefabs/EnemySpitter.prefab index b0a2b1c81..dcda24da2 100644 --- a/Assets/_Project/Prefabs/EnemySpitter.prefab +++ b/Assets/_Project/Prefabs/EnemySpitter.prefab @@ -873,10 +873,10 @@ MonoBehaviour: m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring MaxHealth: 28 HitRadius: 1 - MoveSpeed: 3 + MoveSpeed: 2.4 AttackRange: 1.8 AttackDamage: 8 - AttackCooldownTicks: 66 + AttackCooldownTicks: 90 --- !u!95 &13761174629013833 Animator: serializedVersion: 7 @@ -938,8 +938,8 @@ MonoBehaviour: m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring PreferredRange: 9 RangeTolerance: 1.5 - ProjectileSpeed: 11 - CorneredRange: 3 + ProjectileSpeed: 8 + CorneredRange: 6 WindupTicks: 26 --- !u!1 &3924377442331254583 GameObject: diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 71a17abef..59c2560b6 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -62,7 +62,8 @@ namespace ProjectM.Client bool _slashActive; 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) - 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; Material _dangerMat; readonly Dictionary _dangerZones = new(); @@ -249,6 +250,8 @@ namespace ProjectM.Client PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); + if (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) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); if (isEnemy) @@ -304,6 +307,8 @@ namespace ProjectM.Client PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); 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 if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); @@ -413,7 +418,7 @@ namespace ProjectM.Client if (finisher) { 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; @@ -445,9 +450,29 @@ namespace ProjectM.Client } float coneRange = Mathf.Max(0.1f, ceff.Range); 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); 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; @@ -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 // 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. - 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; bool finisher = step >= comboLen; diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 42ed00d97..597db399c 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -3,6 +3,8 @@ using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; 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.UIElements; @@ -341,26 +343,24 @@ namespace ProjectM.Client // 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 - // siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the - // baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change). + // siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so + // the fraction reads true directly. bool bossAlive = false; float bossHp = 0f, bossMax = 0f; if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) { - float bossBakedMax = 0f; foreach (var (bhq, tele, blt) in SystemAPI.Query, RefRO, RefRO>().WithAll()) { 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 (bhq.ValueRO.Max > bossBakedMax) + if (bhq.ValueRO.Max > bossMax) { - bossBakedMax = bhq.ValueRO.Max; + bossMax = bhq.ValueRO.Max; bossHp = bhq.ValueRO.Current; bossAlive = bhq.ValueRO.Current > 0f; } } - bossMax = bossBakedMax * Tuning.BossHealthMultiplier; } UpdateBossBar(bossAlive, bossHp, bossMax); @@ -438,6 +438,10 @@ namespace ProjectM.Client metaShow = true; } 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). // (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 @@ -1834,7 +1838,150 @@ namespace ProjectM.Client _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>().WithAll()) + { + 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(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>().WithAll()) + { + 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 pool, DynamicBuffer record) { if (!show) diff --git a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs new file mode 100644 index 000000000..05b02bc2f --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into + /// 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). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class ClassSelectSendSystem : SystemBase + { + static readonly Queue s_Queue = new(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() => s_Queue.Clear(); + + /// Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this. + public static void RequestClass(byte classId) => s_Queue.Enqueue(classId); + + protected override void OnCreate() => RequireForUpdate(); + + 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() }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta new file mode 100644 index 000000000..167c72eb9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/ClassSelectSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0d67e293cdeb454fbef8414d4aeb813 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs new file mode 100644 index 000000000..836f911d6 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs @@ -0,0 +1,33 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single + /// 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. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class PortalInteractSendSystem : SystemBase + { + static bool s_pending; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() => s_pending = false; + + /// Request leaving via the portal (the HUD prompt / E-key near the portal drives this). + public static void Interact() => s_pending = true; + + protected override void OnCreate() => RequireForUpdate(); + + protected override void OnUpdate() + { + if (!s_pending) return; + s_pending = false; + EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest)); + } + } +} diff --git a/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta new file mode 100644 index 000000000..efc660402 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/PortalInteractSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eac0f1b2340c0b344b430363311d3be5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs new file mode 100644 index 000000000..ec1ea6c37 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into + /// RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server + /// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class PrepPurchaseSendSystem : SystemBase + { + static readonly Queue s_Queue = new(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() => s_Queue.Clear(); + + /// Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this. + public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId); + + protected override void OnCreate() => RequireForUpdate(); + + 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() }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta new file mode 100644 index 000000000..4b3daac76 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/PrepPurchaseSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b60ac8ed7ee3ce4081c55b2188be142 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs new file mode 100644 index 000000000..49b8235b7 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs @@ -0,0 +1,82 @@ +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for — 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 (class seeds + + /// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + . + /// 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). + /// + [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(); + state.RequireForUpdate(state.GetEntityQuery(b)); + state.RequireForUpdate(); + } + + public void OnUpdate(ref SystemState state) + { + bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, e) in + SystemAPI.Query>().WithAll().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(out var metaCat) + && SystemAPI.TryGetSingletonEntity(out dir) && SystemAPI.HasBuffer(dir); + bool haveDb = SystemAPI.TryGetSingleton(out var abilityDb); + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, reqEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + ecb.DestroyEntity(reqEntity); // ALWAYS consumed + if (!accept) continue; + + var conn = receive.ValueRO.SourceConnection; + if (!SystemAPI.HasComponent(conn) + || !playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) + continue; + if (!SystemAPI.HasComponent(player)) continue; + + var mods = SystemAPI.GetBuffer(player); + var metaRecord = haveMeta ? SystemAPI.GetBuffer(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(player)) + SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass }); + if (SystemAPI.HasComponent(player)) + SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now + if (haveDb && SystemAPI.HasComponent(player) && SystemAPI.HasComponent(player)) + { + byte charId = SystemAPI.GetComponent(player).Id; + if (abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar)) + { + var hp = SystemAPI.GetComponent(player); + ClassSwapUtil.HealClamp(ref hp, baseChar.MaxHealth, mods); + SystemAPI.SetComponent(player, hp); + } + } + } + ecb.Playback(state.EntityManager); + ecb.Dispose(); + playerByConn.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs.meta new file mode 100644 index 000000000..435a85d38 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 01a67c1a54ce0574b86700e32e0bdb5b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 5d75bd1ad..4176d6c5a 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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. 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; if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter); xform.ValueRW.Position = sNewPos; diff --git a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs new file mode 100644 index 000000000..9d40b9a30 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs @@ -0,0 +1,79 @@ +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for — 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 — pre-check BEFORE , since Withdraw + /// CLAMPS and never rejects). A purchase appends ONE run-scoped in the prep band + /// ( + 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). + /// + [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(); + state.RequireForUpdate(state.GetEntityQuery(b)); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + public void OnUpdate(ref SystemState state) + { + bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; + var director = SystemAPI.GetSingletonEntity(); + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, e) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = e; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, reqEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + ecb.DestroyEntity(reqEntity); // ALWAYS consumed + if (!accept) continue; + + var conn = receive.ValueRO.SourceConnection; + if (!SystemAPI.HasComponent(conn) + || !playerByConn.TryGetValue(SystemAPI.GetComponent(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(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(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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta new file mode 100644 index 000000000..8f199d737 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f052eb701594a3a42ba83e524dd2d28b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs index db98f6219..559383d50 100644 --- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs @@ -182,60 +182,29 @@ namespace ProjectM.Server if (sender != Entity.Null && SystemAPI.HasComponent(sender) && SystemAPI.HasBuffer(sender)) { - byte newClass = ClassTraits.Normalize((byte)cmd.ArgA); var classMods = SystemAPI.GetBuffer(sender); - ClassTraits.Reapply(newClass, classMods); - SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) }); - - // Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in - // sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta - // rows would survive — strip the meta band, replay the NEW class's persisted tiers (the - // GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later - // MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the - // refill folds the new class's meta MaxHealth too. - TimedModifierUtil.RemoveBySourceIdRange(classMods, Tuning.MetaSourceIdBase, - Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan); - if (SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated - && SystemAPI.TryGetSingletonBuffer(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, - }); - } - } + Entity dir2 = Entity.Null; + bool haveMeta2 = SystemAPI.TryGetSingleton(out var metaCat2) + && SystemAPI.TryGetSingletonEntity(out dir2) && SystemAPI.HasBuffer(dir2); + var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer(dir2) : default; + // DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil, + // used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift. + ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2, + out byte swNewClass, out byte swNewAbility); + SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility }); if (SystemAPI.HasComponent(sender)) - SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass }); - - // Let the swapped Fire ability fire immediately (both abilities share one cooldown gate). + SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass }); if (SystemAPI.HasComponent(sender)) - SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready - - // 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). + SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); if (SystemAPI.HasComponent(sender) && SystemAPI.HasComponent(sender) - && SystemAPI.TryGetSingleton(out var abilityDb)) + && SystemAPI.TryGetSingleton(out var abilityDb2)) { - var hp = SystemAPI.GetComponent(sender); - byte charId = SystemAPI.GetComponent(sender).Id; - if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar)) + byte charId2 = SystemAPI.GetComponent(sender).Id; + if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2)) { - hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods); - SystemAPI.SetComponent(sender, hp); + var hp2 = SystemAPI.GetComponent(sender); + ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods); + SystemAPI.SetComponent(sender, hp2); } } } diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 581c2ed41..3094a8302 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -69,6 +69,8 @@ namespace ProjectM.Server // launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge. ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u }); ecb.AddComponent(director, default(RouteCommand)); + ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch + ecb.AddComponent(director, default(MetaCounters)); // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director diff --git a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs new file mode 100644 index 000000000..dc7a73258 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs @@ -0,0 +1,66 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for — a participant interacting the room-exit portal during + /// RoomExplore. Honored ONLY when RunInfo.Lifecycle==RoomExplore and the sender is an EXPEDITION player + /// (region gate, the RouteSelect idiom). Sets the server-only 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. + /// + [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(); + state.RequireForUpdate(state.GetEntityQuery(b)); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var dirEntity = SystemAPI.GetSingletonEntity(); + bool gateOpen = SystemAPI.GetComponent(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(8, Allocator.Temp); + foreach (var (owner, region) in + SystemAPI.Query, RefRO>().WithAll()) + regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region; + + bool interacted = SystemAPI.GetComponent(dirEntity).HasInteract != 0; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, requestEntity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + { + var conn = receive.ValueRO.SourceConnection; + bool valid = gateOpen && !interacted + && SystemAPI.HasComponent(conn) + && regionByConn.TryGetValue(SystemAPI.GetComponent(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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta new file mode 100644 index 000000000..00d4109e1 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/PortalInteractReceiveSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cfb4147e08bf1b244bef1fa5d71d8b9e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs index 21a61ca4c..9cc28fbfe 100644 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs @@ -173,10 +173,8 @@ namespace ProjectM.Server if (SystemAPI.HasComponent(dirEntity) && SystemAPI.GetComponent(dirEntity).State == ExpeditionObjectiveState.Cleared) { - var ecb = new EntityCommandBuffer(Allocator.Temp); - RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF)); - ecb.Playback(state.EntityManager); - ecb.Dispose(); + // DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through + // RoomReward + the loot window so the party can mine after clearing. run.RoomsClearedThisRun += 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). foreach (var offer in SystemAPI.Query>().WithAll()) 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(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(dirEntity) + && SystemAPI.GetComponent(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(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) { info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner } else { - // Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable - // options (the client map panel is regen-for-display; the clickable buttons bind to these - // bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room - // materializes only when the choice commits. + // Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable + // options; RouteSelect is the teardown gap (the room is gone now). var map = RunMapMath.Generate(run.RunSeed); - int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, - out var cols); + int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols); 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.Lifecycle = RunLifecycle.Returning; } @@ -246,8 +276,6 @@ namespace ProjectM.Server 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; 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(dirEntity)) SystemAPI.SetComponent(dirEntity, default(RouteCommand)); info.Lifecycle = RunLifecycle.RouteSelect; @@ -256,7 +284,8 @@ namespace ProjectM.Server break; } - case RunLifecycle.RouteSelect: + +case RunLifecycle.RouteSelect: { // 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); @@ -365,6 +394,9 @@ namespace ProjectM.Server { TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, 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; } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs new file mode 100644 index 000000000..3687723a1 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs @@ -0,0 +1,16 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server: pick the player's class at base. Server honors it ONLY in RunInfo.Lifecycle==Staging + /// and applies the FULL swap via (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. + /// + public struct ClassSelectRequest : IRpcCommand + { + /// Requested class id (0/unknown → Warrior via ClassTraits.Normalize). + public byte ClassId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs.meta new file mode 100644 index 000000000..0de2065d2 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSelectRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0fcc1501b0c2144585249be7b65dd61 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs new file mode 100644 index 000000000..67dd27353 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs @@ -0,0 +1,65 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// 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: (class-seed band) + the meta + /// band strip + per-class replay. The caller then writes AbilityRef/PlayerClass/ + /// AbilityCooldown and calls (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). + /// + public static class ClassSwapUtil + { + /// Re-seed the class band + re-sync the permanent-meta band for on + /// . Returns the normalized class + its Fire ability id (the caller sets AbilityRef). + /// false (no catalog/record) skips the meta replay (the strip still runs). + public static void Apply(byte rawClass, DynamicBuffer mods, + bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer 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, + }); + } + } + } + + /// Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the + /// just-reseeded , 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<=0) — respawn refills. + public static void HealClamp(ref Health health, float baseMaxHealth, DynamicBuffer mods) + { + if (health.Current > 0f) + health.Current = StatMath.Apply(baseMaxHealth, StatTarget.MaxHealth, mods); + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs.meta new file mode 100644 index 000000000..d5ef0ef82 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSwapUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 546e843270f89d04ca6d6cf816e217d3 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/Health.cs b/Assets/_Project/Scripts/Simulation/Combat/Health.cs index e7420de09..43c65bc08 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/Health.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/Health.cs @@ -14,7 +14,8 @@ namespace ProjectM.Simulation /// Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state. [GhostField] public float Current; - /// Maximum hit points. Baked identically on client and server; not replicated. - public float Max; + /// Maximum hit points. Replicated so a client HUD bar reads a correct fraction even when Max is + /// 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). + [GhostField] public float Max; } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs new file mode 100644 index 000000000..023338735 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs @@ -0,0 +1,42 @@ +namespace ProjectM.Simulation +{ + /// 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. + public struct PrepRow + { + public byte Id; + public byte CostResId; // ResourceId.* + public int Cost; + public byte Target; // StatTarget + public byte Op; // ModOp + public float Value; + } + + /// + /// 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 in the prep SourceId band + /// ( + Id), which '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). + /// + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta new file mode 100644 index 000000000..a6547d3f7 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/PrepCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef0c16b1e46d22c42bf38db14b2983b5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs new file mode 100644 index 000000000..efb90d330 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs @@ -0,0 +1,16 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server: buy a base PREP-LOADOUT option ( id). Honored ONLY in Staging; the + /// server prices it from the catalog (never on the wire), does an in-loop + /// pre-check BEFORE (DR-014 atomicity), and appends the run-scoped + /// once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type. + /// + public struct PrepPurchaseRequest : IRpcCommand + { + /// Prep-catalog option id. + public byte OptionId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta new file mode 100644 index 000000000..eff8797ee --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/PrepPurchaseRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e945968f38977974f926709051f28609 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs index b9e2c7535..aa26d302f 100644 --- a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs @@ -39,6 +39,19 @@ namespace ProjectM.Simulation public int ForLayer; } + /// + /// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem + /// sets 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). + /// + public struct PortalCommand : IComponentData + { + /// 1 once a participant has interacted the room-exit portal this RoomExplore. + public byte HasInteract; + } + + /// /// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated for /// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 9ae05e2d6..346baea15 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -129,6 +129,19 @@ namespace ProjectM.Simulation /// never floods past readable (also bounded by the director's MaxAlive count). public const int BossSummonLiveCap = 8; + // ---- DR-046 room-exit PORTAL / loot window ---- + + /// 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. + public const float PortalOffsetZ = -5f; + + /// How close the local player must be to the portal to show 'E to LEAVE' + send the interact. + public const float PortalInteractRange = 3.5f; + + /// RoomExplore soft-timeout (~30 s @60): auto-advance if nobody interacts the portal (no softlock). + public const uint ExploreGraceTicks = 1800; + + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- @@ -154,6 +167,14 @@ namespace ProjectM.Simulation /// Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count. public const uint BoonSourceIdSpan = 0x10000u; + /// 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. + public const uint PrepSourceIdBase = 0x00D00000u; + /// Width of the prep band (far above the tiny option count). + public const uint PrepSourceIdSpan = 0x10000u; + + /// 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 /// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn. diff --git a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs new file mode 100644 index 000000000..51c03e507 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs @@ -0,0 +1,12 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// 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 for RunDirectorSystem (the sole RunInfo writer) + /// to consume. Empty payload. UNCONDITIONAL wire type. + /// + public struct PortalInteractRequest : IRpcCommand { } +} diff --git a/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta new file mode 100644 index 000000000..6ae14ad7b --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/PortalInteractRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d527637b76c7a545817f6f388533248 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs index d13f129f4..343aeb0dd 100644 --- a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs +++ b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs @@ -24,6 +24,11 @@ namespace ProjectM.Simulation public const byte Returning = 4; /// Boons picked; party choosing the next branch (no room materialized — the teardown gap). public const byte RouteSelect = 5; + /// 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. + public const byte RoomExplore = 6; + } /// diff --git a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs index 5c0b2e9f8..b1d03f7ca 100644 --- a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs +++ b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs @@ -48,6 +48,11 @@ namespace ProjectM.Simulation /// Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared). public uint RouteGraceTick; + /// 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. + public uint ExploreGraceTick; + + // ---- boons ---- /// Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run. public uint BoonPickCounter; diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs index dc34a8097..06908006f 100644 --- a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs +++ b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs @@ -35,7 +35,7 @@ namespace ProjectM.Tests map = RunMapMath.Generate(Seed); 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 { Lifecycle = RunLifecycle.InRoom, @@ -73,7 +73,15 @@ namespace ProjectM.Tests static void MarkCleared(EntityManager em, Entity dir) => 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)); int n = q.CalculateEntityCount(); @@ -82,48 +90,48 @@ namespace ProjectM.Tests } [Test] - public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump() + public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump() { var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; - // Room-0 content that must die at the RoomReward entry. var node0 = em.CreateEntity(typeof(RoomTag)); em.SetComponentData(node0, new RoomTag { Room = 0 }); 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(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(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(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(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"); - // 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 expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col : pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col; 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(dir); var run = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); 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(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed"); Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published"); Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType); Assert.AreEqual(0, em.GetComponentData(dir).HasPick, "latch consumed"); Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance"); - Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 1499f, - "party teleported onto the idle sub-slot (+1500)"); + Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 1499f, "party teleported (+1500)"); world.Dispose(); } @@ -134,7 +142,6 @@ namespace ProjectM.Tests var (world, group, dir, player) = MakeMidRunWorld(0, out map0); var em = world.EntityManager; int bossLayer = map0.LayerCount - 1; - // Jump the state to the boss room. var info0 = em.GetComponentData(dir); info0.CurrentRoom = bossLayer; em.SetComponentData(dir, info0); @@ -145,8 +152,9 @@ namespace ProjectM.Tests em.SetComponentData(dir, run0); MarkCleared(em, dir); - group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1) - group.Update(); // RoomReward -> Returning + group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists) + group.Update(); // RoomReward -> RoomExplore + PortalAdvance(em, group, dir); // portal -> Returning (boss cleared) group.Update(); // Returning: bank + teleport home -> Staging var info = em.GetComponentData(dir); @@ -162,7 +170,7 @@ namespace ProjectM.Tests Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested"); 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(); Assert.AreEqual(1, em.GetComponentData(dir).Charge, "no double credit (F7)"); Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted); @@ -197,17 +205,17 @@ namespace ProjectM.Tests var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); - group.Update(); // -> RoomReward (teardown) - group.Update(); // -> RouteSelect (gate open, grace armed at T0) + group.Update(); + group.Update(); // -> RoomExplore + PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed) var gate = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle); byte pickIdx = (byte)(gate.RouteOptionCount - 1); 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 }); - SetTick(world, T0 + 100000); // way past any grace + SetTick(world, T0 + 100000); group.Update(); var info = em.GetComponentData(dir); @@ -222,12 +230,13 @@ namespace ProjectM.Tests var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); - group.Update(); // -> RoomReward - group.Update(); // -> RouteSelect + group.Update(); + group.Update(); // -> RoomExplore + PortalAdvance(em, group, dir); // -> RouteSelect var gate = em.GetComponentData(dir); byte lowestCol = gate.RouteOpt0Col; - SetTick(world, T0 + 100000); // grace elapses, nobody picked + SetTick(world, T0 + 100000); group.Update(); var info = em.GetComponentData(dir); @@ -242,8 +251,9 @@ namespace ProjectM.Tests var (world, group, dir, player) = MakeMidRunWorld(0, out var map); var em = world.EntityManager; MarkCleared(em, dir); - group.Update(); // -> RoomReward - group.Update(); // -> RouteSelect + group.Update(); + group.Update(); // -> RoomExplore + PortalAdvance(em, group, dir); // -> RouteSelect Assert.Greater((int)em.GetComponentData(dir).RouteOptionCount, 0); em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left @@ -251,7 +261,7 @@ namespace ProjectM.Tests var info = em.GetComponentData(dir); 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(); } }