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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user