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
@@ -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<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{
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<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)
{
if (!show)