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). /// Re-seed the class band + re-sync the permanent-meta band for on /// . Returns the normalized class (the caller writes FrameId/PlayerClass + re-seeds /// the socket loadout). 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) { newClass = ClassTraits.Normalize(rawClass); ClassTraits.Reapply(newClass, mods); // 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); } } }