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
@@ -182,60 +182,29 @@ namespace ProjectM.Server
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
&& SystemAPI.HasBuffer<StatModifier>(sender))
{
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
var classMods = SystemAPI.GetBuffer<StatModifier>(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<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,
});
}
}
Entity dir2 = Entity.Null;
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(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<PlayerClass>(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<AbilityCooldown>(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<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 charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
byte charId2 = SystemAPI.GetComponent<CharacterStatsRef>(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<Health>(sender);
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp2);
}
}
}