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:
@@ -0,0 +1,16 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server: pick the player's class at base. Server honors it ONLY in <c>RunInfo.Lifecycle==Staging</c>
|
||||
/// and applies the FULL swap via <see cref="ClassSwapUtil"/> (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.
|
||||
/// </summary>
|
||||
public struct ClassSelectRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Requested class id (0/unknown → Warrior via ClassTraits.Normalize).</summary>
|
||||
public byte ClassId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0fcc1501b0c2144585249be7b65dd61
|
||||
@@ -0,0 +1,65 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// 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: <see cref="ClassTraits.Reapply"/> (class-seed band) + the meta
|
||||
/// band strip + per-class <see cref="MetaTierState"/> replay. The caller then writes AbilityRef/PlayerClass/
|
||||
/// AbilityCooldown and calls <see cref="HealClamp"/> (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).
|
||||
/// </summary>
|
||||
public static class ClassSwapUtil
|
||||
{
|
||||
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
|
||||
/// <paramref name="mods"/>. Returns the normalized class + its Fire ability id (the caller sets AbilityRef).
|
||||
/// <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the strip still runs).</summary>
|
||||
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
|
||||
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the
|
||||
/// just-reseeded <paramref name="mods"/>, 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.</summary>
|
||||
public static void HealClamp(ref Health health, float baseMaxHealth, DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
if (health.Current > 0f)
|
||||
health.Current = StatMath.Apply(baseMaxHealth, StatTarget.MaxHealth, mods);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 546e843270f89d04ca6d6cf816e217d3
|
||||
@@ -14,7 +14,8 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary>
|
||||
[GhostField] public float Current;
|
||||
|
||||
/// <summary>Maximum hit points. Baked identically on client and server; not replicated.</summary>
|
||||
public float Max;
|
||||
/// <summary>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).</summary>
|
||||
[GhostField] public float Max;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
public struct PrepRow
|
||||
{
|
||||
public byte Id;
|
||||
public byte CostResId; // ResourceId.*
|
||||
public int Cost;
|
||||
public byte Target; // StatTarget
|
||||
public byte Op; // ModOp
|
||||
public float Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="StatModifier"/> in the prep SourceId band
|
||||
/// (<see cref="Tuning.PrepSourceIdBase"/> + Id), which <see cref="Server"/>'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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef0c16b1e46d22c42bf38db14b2983b5
|
||||
@@ -0,0 +1,16 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server: buy a base PREP-LOADOUT option (<see cref="PrepCatalog"/> id). Honored ONLY in Staging; the
|
||||
/// server prices it from the catalog (never on the wire), does an in-loop <see cref="StorageMath.TotalOf"/>
|
||||
/// pre-check BEFORE <see cref="StorageMath.Withdraw"/> (DR-014 atomicity), and appends the run-scoped
|
||||
/// <see cref="StatModifier"/> once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type.
|
||||
/// </summary>
|
||||
public struct PrepPurchaseRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Prep-catalog option id.</summary>
|
||||
public byte OptionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e945968f38977974f926709051f28609
|
||||
@@ -39,6 +39,19 @@ namespace ProjectM.Simulation
|
||||
public int ForLayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem
|
||||
/// sets <see cref="HasInteract"/> 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).
|
||||
/// </summary>
|
||||
public struct PortalCommand : IComponentData
|
||||
{
|
||||
/// <summary>1 once a participant has interacted the room-exit portal this RoomExplore.</summary>
|
||||
public byte HasInteract;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for
|
||||
/// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot
|
||||
|
||||
@@ -129,6 +129,19 @@ namespace ProjectM.Simulation
|
||||
/// never floods past readable (also bounded by the director's MaxAlive count).</summary>
|
||||
public const int BossSummonLiveCap = 8;
|
||||
|
||||
// ---- DR-046 room-exit PORTAL / loot window ----
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public const float PortalOffsetZ = -5f;
|
||||
|
||||
/// <summary>How close the local player must be to the portal to show 'E to LEAVE' + send the interact.</summary>
|
||||
public const float PortalInteractRange = 3.5f;
|
||||
|
||||
/// <summary>RoomExplore soft-timeout (~30 s @60): auto-advance if nobody interacts the portal (no softlock).</summary>
|
||||
public const uint ExploreGraceTicks = 1800;
|
||||
|
||||
|
||||
|
||||
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
||||
|
||||
@@ -154,6 +167,14 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
|
||||
public const uint BoonSourceIdSpan = 0x10000u;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public const uint PrepSourceIdBase = 0x00D00000u;
|
||||
/// <summary>Width of the prep band (far above the tiny option count).</summary>
|
||||
public const uint PrepSourceIdSpan = 0x10000u;
|
||||
|
||||
|
||||
/// <summary>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.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="PortalCommand"/> for RunDirectorSystem (the sole RunInfo writer)
|
||||
/// to consume. Empty payload. UNCONDITIONAL wire type.
|
||||
/// </summary>
|
||||
public struct PortalInteractRequest : IRpcCommand { }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d527637b76c7a545817f6f388533248
|
||||
@@ -24,6 +24,11 @@ namespace ProjectM.Simulation
|
||||
public const byte Returning = 4;
|
||||
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
|
||||
public const byte RouteSelect = 5;
|
||||
/// <summary>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.</summary>
|
||||
public const byte RoomExplore = 6;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -48,6 +48,11 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
|
||||
public uint RouteGraceTick;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public uint ExploreGraceTick;
|
||||
|
||||
|
||||
// ---- boons ----
|
||||
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
|
||||
public uint BoonPickCounter;
|
||||
|
||||
Reference in New Issue
Block a user