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:
@@ -873,10 +873,10 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||||
MaxHealth: 28
|
MaxHealth: 28
|
||||||
HitRadius: 1
|
HitRadius: 1
|
||||||
MoveSpeed: 3
|
MoveSpeed: 2.4
|
||||||
AttackRange: 1.8
|
AttackRange: 1.8
|
||||||
AttackDamage: 8
|
AttackDamage: 8
|
||||||
AttackCooldownTicks: 66
|
AttackCooldownTicks: 90
|
||||||
--- !u!95 &13761174629013833
|
--- !u!95 &13761174629013833
|
||||||
Animator:
|
Animator:
|
||||||
serializedVersion: 7
|
serializedVersion: 7
|
||||||
@@ -938,8 +938,8 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring
|
||||||
PreferredRange: 9
|
PreferredRange: 9
|
||||||
RangeTolerance: 1.5
|
RangeTolerance: 1.5
|
||||||
ProjectileSpeed: 11
|
ProjectileSpeed: 8
|
||||||
CorneredRange: 3
|
CorneredRange: 6
|
||||||
WindupTicks: 26
|
WindupTicks: 26
|
||||||
--- !u!1 &3924377442331254583
|
--- !u!1 &3924377442331254583
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ namespace ProjectM.Client
|
|||||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||||
|
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||||
bool _coneTickInit;
|
bool _coneTickInit;
|
||||||
Material _dangerMat;
|
Material _dangerMat;
|
||||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||||
@@ -249,6 +250,8 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
|
||||||
|
|
||||||
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||||
if (isEnemy)
|
if (isEnemy)
|
||||||
@@ -304,6 +307,8 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
TryHold(); // C4: kill crunch (throttled)
|
||||||
|
|
||||||
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||||
@@ -413,7 +418,7 @@ namespace ProjectM.Client
|
|||||||
if (finisher)
|
if (finisher)
|
||||||
{
|
{
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
||||||
if (FeelConfig.HitStopFreezeEnabled) PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
|
TryHold(); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastLocalSwingTick = mc.SwingStartTick;
|
_lastLocalSwingTick = mc.SwingStartTick;
|
||||||
@@ -445,9 +450,29 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
float coneRange = Mathf.Max(0.1f, ceff.Range);
|
float coneRange = Mathf.Max(0.1f, ceff.Range);
|
||||||
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
|
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
|
||||||
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
|
// C3: client cone-overlap over the cached enemy snapshot -> an immediate "you hit" read + thunk
|
||||||
|
// (the server-only cone damage arrives a few ticks later), mirroring the melee connect path.
|
||||||
|
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
|
||||||
|
float coneCos = Mathf.Cos(coneHalf);
|
||||||
|
float2 cfdir = new float2(cface.x, cface.z);
|
||||||
|
foreach (var kv in _cache)
|
||||||
|
{
|
||||||
|
if (!kv.Value.IsEnemy) continue;
|
||||||
|
if (MeleeConeMath.InCone(localPos, cfdir, coneRange, coneCos, kv.Value.Pos))
|
||||||
|
{
|
||||||
|
float cd2 = math.distancesq(localPos, kv.Value.Pos);
|
||||||
|
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, coneConnected);
|
||||||
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
||||||
PrototypeCameraRig.AddShake(0.06f);
|
PrototypeCameraRig.AddShake(0.06f);
|
||||||
|
if (coneConnected)
|
||||||
|
{
|
||||||
|
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||||
|
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
|
||||||
|
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastConeFireTick = nextFire;
|
_lastConeFireTick = nextFire;
|
||||||
@@ -813,7 +838,18 @@ namespace ProjectM.Client
|
|||||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||||
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
||||||
// directional, escalating cleave rather than a static flash.
|
// directional, escalating cleave rather than a static flash.
|
||||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
|
||||||
|
void TryHold()
|
||||||
|
{
|
||||||
|
if (!FeelConfig.HitStopFreezeEnabled) return;
|
||||||
|
double now = SystemAPI.Time.ElapsedTime;
|
||||||
|
if (now - _lastHoldTime < 0.22) return;
|
||||||
|
_lastHoldTime = now;
|
||||||
|
PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
||||||
{
|
{
|
||||||
if (_slashMr == null || _slashMat == null) return;
|
if (_slashMr == null || _slashMat == null) return;
|
||||||
bool finisher = step >= comboLen;
|
bool finisher = step >= comboLen;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using ProjectM.Simulation;
|
|||||||
using Unity.Entities;
|
using Unity.Entities;
|
||||||
using Unity.NetCode;
|
using Unity.NetCode;
|
||||||
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
|
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;
|
||||||
using UnityEngine.UIElements;
|
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)
|
// 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
|
// 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
|
// siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so
|
||||||
// baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change).
|
// the fraction reads true directly.
|
||||||
bool bossAlive = false;
|
bool bossAlive = false;
|
||||||
float bossHp = 0f, bossMax = 0f;
|
float bossHp = 0f, bossMax = 0f;
|
||||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
||||||
{
|
{
|
||||||
float bossBakedMax = 0f;
|
|
||||||
foreach (var (bhq, tele, blt) in
|
foreach (var (bhq, tele, blt) in
|
||||||
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
|
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 (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 (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;
|
bossHp = bhq.ValueRO.Current;
|
||||||
bossAlive = bhq.ValueRO.Current > 0f;
|
bossAlive = bhq.ValueRO.Current > 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
|
|
||||||
}
|
}
|
||||||
UpdateBossBar(bossAlive, bossHp, bossMax);
|
UpdateBossBar(bossAlive, bossHp, bossMax);
|
||||||
|
|
||||||
@@ -438,6 +438,10 @@ namespace ProjectM.Client
|
|||||||
metaShow = true;
|
metaShow = true;
|
||||||
}
|
}
|
||||||
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
|
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).
|
// 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.)
|
// (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
|
// 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;
|
_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)
|
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||||
{
|
{
|
||||||
if (!show)
|
if (!show)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into
|
||||||
|
/// <see cref="ClassSelectRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the class id; the server
|
||||||
|
/// re-validates the phase + applies the full swap. Statics reset on play-enter (the stale-bridge hazard).
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
public partial class ClassSelectSendSystem : SystemBase
|
||||||
|
{
|
||||||
|
static readonly Queue<byte> s_Queue = new();
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetStatics() => s_Queue.Clear();
|
||||||
|
|
||||||
|
/// <summary>Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this.</summary>
|
||||||
|
public static void RequestClass(byte classId) => s_Queue.Enqueue(classId);
|
||||||
|
|
||||||
|
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
while (s_Queue.Count > 0)
|
||||||
|
{
|
||||||
|
var req = EntityManager.CreateEntity(typeof(ClassSelectRequest), typeof(SendRpcCommandRequest));
|
||||||
|
EntityManager.SetComponentData(req, new ClassSelectRequest { ClassId = s_Queue.Dequeue() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e0d67e293cdeb454fbef8414d4aeb813
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single
|
||||||
|
/// <see cref="PortalInteractRequest"/> RPC. Coalesced (one per drain — repeat E while the server is still in
|
||||||
|
/// RoomExplore is harmless, the server latches once). Statics reset on play-enter.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
public partial class PortalInteractSendSystem : SystemBase
|
||||||
|
{
|
||||||
|
static bool s_pending;
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetStatics() => s_pending = false;
|
||||||
|
|
||||||
|
/// <summary>Request leaving via the portal (the HUD prompt / E-key near the portal drives this).</summary>
|
||||||
|
public static void Interact() => s_pending = true;
|
||||||
|
|
||||||
|
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
if (!s_pending) return;
|
||||||
|
s_pending = false;
|
||||||
|
EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eac0f1b2340c0b344b430363311d3be5
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into
|
||||||
|
/// <see cref="PrepPurchaseRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server
|
||||||
|
/// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
public partial class PrepPurchaseSendSystem : SystemBase
|
||||||
|
{
|
||||||
|
static readonly Queue<byte> s_Queue = new();
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetStatics() => s_Queue.Clear();
|
||||||
|
|
||||||
|
/// <summary>Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this.</summary>
|
||||||
|
public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId);
|
||||||
|
|
||||||
|
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
while (s_Queue.Count > 0)
|
||||||
|
{
|
||||||
|
var req = EntityManager.CreateEntity(typeof(PrepPurchaseRequest), typeof(SendRpcCommandRequest));
|
||||||
|
EntityManager.SetComponentData(req, new PrepPurchaseRequest { OptionId = s_Queue.Dequeue() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5b60ac8ed7ee3ce4081c55b2188be142
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their class at base. Honored ONLY in
|
||||||
|
/// Staging (class = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
|
||||||
|
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
|
||||||
|
/// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + <see cref="ClassSwapUtil.HealClamp"/>.
|
||||||
|
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. NOT
|
||||||
|
/// Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC — Burst safety over micro-perf).
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
|
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||||
|
public partial struct ClassSelectReceiveSystem : ISystem
|
||||||
|
{
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
|
||||||
|
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||||
|
state.RequireForUpdate<RunInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||||
|
|
||||||
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, e) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||||
|
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||||
|
|
||||||
|
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded).
|
||||||
|
Entity dir = Entity.Null;
|
||||||
|
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
|
||||||
|
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
|
||||||
|
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
|
||||||
|
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
foreach (var (receive, req, reqEntity) in
|
||||||
|
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ClassSelectRequest>>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
|
||||||
|
if (!accept) continue;
|
||||||
|
|
||||||
|
var conn = receive.ValueRO.SourceConnection;
|
||||||
|
if (!SystemAPI.HasComponent<NetworkId>(conn)
|
||||||
|
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||||
|
continue;
|
||||||
|
if (!SystemAPI.HasComponent<AbilityRef>(player)) continue;
|
||||||
|
|
||||||
|
var mods = SystemAPI.GetBuffer<StatModifier>(player);
|
||||||
|
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
|
||||||
|
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord,
|
||||||
|
out byte newClass, out byte newAbilityId);
|
||||||
|
|
||||||
|
SystemAPI.SetComponent(player, new AbilityRef { Id = newAbilityId });
|
||||||
|
if (SystemAPI.HasComponent<PlayerClass>(player))
|
||||||
|
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
|
||||||
|
if (SystemAPI.HasComponent<AbilityCooldown>(player))
|
||||||
|
SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now
|
||||||
|
if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player))
|
||||||
|
{
|
||||||
|
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
|
||||||
|
if (abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
|
||||||
|
{
|
||||||
|
var hp = SystemAPI.GetComponent<Health>(player);
|
||||||
|
ClassSwapUtil.HealClamp(ref hp, baseChar.MaxHealth, mods);
|
||||||
|
SystemAPI.SetComponent(player, hp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ecb.Playback(state.EntityManager);
|
||||||
|
ecb.Dispose();
|
||||||
|
playerByConn.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 01a67c1a54ce0574b86700e32e0bdb5b
|
||||||
@@ -397,7 +397,11 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
||||||
var sp = spitter.ValueRO;
|
var sp = spitter.ValueRO;
|
||||||
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
|
// Once the player has closed inside CorneredRange the Spitter STANDS (no flee) + point-blanks — so a
|
||||||
|
// melee player who commits can actually catch it (fixes the endless-kite complaint; the spit is dash-dodgeable).
|
||||||
|
bool sCorneredMove = math.distance(pos.xz, sTargetPos.xz) <= sp.CorneredRange;
|
||||||
|
float3 bandVel = sCorneredMove ? float3.zero
|
||||||
|
: EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
|
||||||
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
|
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
|
||||||
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
||||||
xform.ValueRW.Position = sNewPos;
|
xform.ValueRW.Position = sNewPos;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server receiver for <see cref="PrepPurchaseRequest"/> — the base PREP-LOADOUT spend (DR-046). Modeled on
|
||||||
|
/// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity
|
||||||
|
/// idiom — <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/>, since Withdraw
|
||||||
|
/// CLAMPS and never rejects). A purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep band
|
||||||
|
/// (<see cref="Tuning.PrepSourceIdBase"/> + option id) on the BUYER only (prep is personal). "Once per run" needs
|
||||||
|
/// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so
|
||||||
|
/// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem;
|
||||||
|
/// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency).
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
|
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||||
|
public partial struct PrepPurchaseSystem : ISystem
|
||||||
|
{
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PrepPurchaseRequest, ReceiveRpcCommandRequest>();
|
||||||
|
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||||
|
state.RequireForUpdate<RunInfo>();
|
||||||
|
state.RequireForUpdate<ResourceLedger>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||||
|
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
||||||
|
|
||||||
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, e) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||||
|
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||||
|
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
foreach (var (receive, req, reqEntity) in
|
||||||
|
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<PrepPurchaseRequest>>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
|
||||||
|
if (!accept) continue;
|
||||||
|
|
||||||
|
var conn = receive.ValueRO.SourceConnection;
|
||||||
|
if (!SystemAPI.HasComponent<NetworkId>(conn)
|
||||||
|
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
|
||||||
|
continue;
|
||||||
|
if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop
|
||||||
|
|
||||||
|
uint sourceId = Tuning.PrepSourceIdBase + row.Id;
|
||||||
|
var mods = SystemAPI.GetBuffer<StatModifier>(buyer);
|
||||||
|
bool already = false;
|
||||||
|
for (int m = 0; m < mods.Length; m++)
|
||||||
|
if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning)
|
||||||
|
if (already) continue;
|
||||||
|
|
||||||
|
// LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass).
|
||||||
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
|
||||||
|
if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS
|
||||||
|
StorageMath.Withdraw(ledger, row.CostResId, row.Cost);
|
||||||
|
|
||||||
|
mods.Add(new StatModifier
|
||||||
|
{
|
||||||
|
Target = row.Target,
|
||||||
|
Op = row.Op,
|
||||||
|
Value = row.Value,
|
||||||
|
SourceId = sourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ecb.Playback(state.EntityManager);
|
||||||
|
ecb.Dispose();
|
||||||
|
playerByConn.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f052eb701594a3a42ba83e524dd2d28b
|
||||||
@@ -182,60 +182,29 @@ namespace ProjectM.Server
|
|||||||
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
|
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
|
||||||
&& SystemAPI.HasBuffer<StatModifier>(sender))
|
&& SystemAPI.HasBuffer<StatModifier>(sender))
|
||||||
{
|
{
|
||||||
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
|
|
||||||
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
|
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
|
||||||
ClassTraits.Reapply(newClass, classMods);
|
Entity dir2 = Entity.Null;
|
||||||
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) });
|
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
|
||||||
|
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
|
||||||
// Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in
|
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
|
||||||
// sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta
|
// DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil,
|
||||||
// rows would survive — strip the meta band, replay the NEW class's persisted tiers (the
|
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
|
||||||
// GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later
|
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
|
||||||
// MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the
|
out byte swNewClass, out byte swNewAbility);
|
||||||
// refill folds the new class's meta MaxHealth too.
|
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (SystemAPI.HasComponent<PlayerClass>(sender))
|
if (SystemAPI.HasComponent<PlayerClass>(sender))
|
||||||
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass });
|
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
|
||||||
|
|
||||||
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
|
|
||||||
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
|
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
|
||||||
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready
|
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
|
||||||
|
|
||||||
// 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).
|
|
||||||
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
|
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 charId2 = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
|
||||||
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
|
if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
|
||||||
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
|
|
||||||
{
|
{
|
||||||
hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods);
|
var hp2 = SystemAPI.GetComponent<Health>(sender);
|
||||||
SystemAPI.SetComponent(sender, hp);
|
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
|
||||||
|
SystemAPI.SetComponent(sender, hp2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ namespace ProjectM.Server
|
|||||||
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
|
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
|
||||||
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
||||||
ecb.AddComponent(director, default(RouteCommand));
|
ecb.AddComponent(director, default(RouteCommand));
|
||||||
|
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
|
||||||
|
|
||||||
ecb.AddComponent(director, default(MetaCounters));
|
ecb.AddComponent(director, default(MetaCounters));
|
||||||
|
|
||||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server receiver for <see cref="PortalInteractRequest"/> — a participant interacting the room-exit portal during
|
||||||
|
/// RoomExplore. Honored ONLY when <c>RunInfo.Lifecycle==RoomExplore</c> and the sender is an EXPEDITION player
|
||||||
|
/// (region gate, the RouteSelect idiom). Sets the server-only <see cref="PortalCommand"/> latch IN-PLACE; it does
|
||||||
|
/// NOT write RunInfo or tear the room down — RunDirectorSystem (the sole FSM/teardown owner) consumes the latch and
|
||||||
|
/// advances. Plain server group, before RunDirectorSystem; requests ALWAYS destroyed; NO CyclePhase edge.
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
|
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||||
|
public partial struct PortalInteractReceiveSystem : ISystem
|
||||||
|
{
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PortalInteractRequest, ReceiveRpcCommandRequest>();
|
||||||
|
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||||
|
state.RequireForUpdate<RunInfo>();
|
||||||
|
state.RequireForUpdate<PortalCommand>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||||
|
bool gateOpen = SystemAPI.GetComponent<RunInfo>(dirEntity).Lifecycle == RunLifecycle.RoomExplore;
|
||||||
|
|
||||||
|
// Sender region lookup (N3 idiom): a base-bound joiner cannot pull the party out of the room.
|
||||||
|
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, region) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||||
|
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
|
||||||
|
|
||||||
|
bool interacted = SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
||||||
|
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
foreach (var (receive, requestEntity) in
|
||||||
|
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<PortalInteractRequest>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
var conn = receive.ValueRO.SourceConnection;
|
||||||
|
bool valid = gateOpen && !interacted
|
||||||
|
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||||
|
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
|
||||||
|
&& senderRegion == RegionId.Expedition;
|
||||||
|
if (valid)
|
||||||
|
{
|
||||||
|
SystemAPI.SetComponent(dirEntity, new PortalCommand { HasInteract = 1 });
|
||||||
|
interacted = true;
|
||||||
|
}
|
||||||
|
ecb.DestroyEntity(requestEntity);
|
||||||
|
}
|
||||||
|
ecb.Playback(state.EntityManager);
|
||||||
|
ecb.Dispose();
|
||||||
|
regionByConn.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cfb4147e08bf1b244bef1fa5d71d8b9e
|
||||||
@@ -173,10 +173,8 @@ namespace ProjectM.Server
|
|||||||
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
|
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
|
||||||
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
||||||
{
|
{
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
// DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
|
||||||
RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF));
|
// RoomReward + the loot window so the party can mine after clearing.
|
||||||
ecb.Playback(state.EntityManager);
|
|
||||||
ecb.Dispose();
|
|
||||||
|
|
||||||
run.RoomsClearedThisRun += 1;
|
run.RoomsClearedThisRun += 1;
|
||||||
if (info.CurrentRoom >= info.RoomCount - 1)
|
if (info.CurrentRoom >= info.RoomCount - 1)
|
||||||
@@ -215,23 +213,55 @@ namespace ProjectM.Server
|
|||||||
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
|
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
|
||||||
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||||
offer.ValueRW = default;
|
offer.ValueRW = default;
|
||||||
|
// DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist
|
||||||
|
// (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout.
|
||||||
|
run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks);
|
||||||
|
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
||||||
|
SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window
|
||||||
|
info.Lifecycle = RunLifecycle.RoomExplore;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RunLifecycle.RoomExplore:
|
||||||
|
{
|
||||||
|
// DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a
|
||||||
|
// participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft
|
||||||
|
// timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell).
|
||||||
|
if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win
|
||||||
|
{ // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment.
|
||||||
|
run.ExploreGraceTick = 0u;
|
||||||
|
info.Lifecycle = RunLifecycle.Returning;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bool portalUsed = SystemAPI.HasComponent<PortalCommand>(dirEntity)
|
||||||
|
&& SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
||||||
|
bool exploreTimedOut = run.ExploreGraceTick == 0u
|
||||||
|
|| !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick);
|
||||||
|
if (!portalUsed && !exploreTimedOut)
|
||||||
|
break; // still looting
|
||||||
|
|
||||||
|
run.ExploreGraceTick = 0u;
|
||||||
|
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
||||||
|
SystemAPI.SetComponent(dirEntity, default(PortalCommand));
|
||||||
|
|
||||||
|
// The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance.
|
||||||
|
var exploreEcb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF));
|
||||||
|
exploreEcb.Playback(state.EntityManager);
|
||||||
|
exploreEcb.Dispose();
|
||||||
|
|
||||||
if (run.LastTerminalCleared != 0)
|
if (run.LastTerminalCleared != 0)
|
||||||
{
|
{
|
||||||
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable
|
// Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
|
||||||
// options (the client map panel is regen-for-display; the clickable buttons bind to these
|
// options; RouteSelect is the teardown gap (the room is gone now).
|
||||||
// bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room
|
|
||||||
// materializes only when the choice commits.
|
|
||||||
var map = RunMapMath.Generate(run.RunSeed);
|
var map = RunMapMath.Generate(run.RunSeed);
|
||||||
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol,
|
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
|
||||||
out var cols);
|
|
||||||
if (optionCount == 0)
|
if (optionCount == 0)
|
||||||
{
|
{
|
||||||
// Unreachable by construction (every non-terminal node has an out-edge) — a future
|
|
||||||
// generator regression must abort CLEANLY, never wedge on stale options (review F4).
|
|
||||||
info.RouteOptionCount = 0;
|
info.RouteOptionCount = 0;
|
||||||
info.Lifecycle = RunLifecycle.Returning;
|
info.Lifecycle = RunLifecycle.Returning;
|
||||||
}
|
}
|
||||||
@@ -246,8 +276,6 @@ namespace ProjectM.Server
|
|||||||
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
|
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
|
||||||
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
|
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
|
||||||
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
|
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
|
||||||
// Entry-clear: any accepted pick provably belongs to THIS gate (RouteSelectSystem runs
|
|
||||||
// BEFORE this system, so it cannot accept on the entry tick).
|
|
||||||
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
||||||
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
||||||
info.Lifecycle = RunLifecycle.RouteSelect;
|
info.Lifecycle = RunLifecycle.RouteSelect;
|
||||||
@@ -256,7 +284,8 @@ namespace ProjectM.Server
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case RunLifecycle.RouteSelect:
|
|
||||||
|
case RunLifecycle.RouteSelect:
|
||||||
{
|
{
|
||||||
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
|
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
|
||||||
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
|
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
|
||||||
@@ -365,6 +394,9 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
||||||
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
||||||
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
|
||||||
|
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
|
||||||
|
|
||||||
offer.ValueRW = default;
|
offer.ValueRW = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
/// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary>
|
||||||
[GhostField] public float Current;
|
[GhostField] public float Current;
|
||||||
|
|
||||||
/// <summary>Maximum hit points. Baked identically on client and server; not replicated.</summary>
|
/// <summary>Maximum hit points. Replicated so a client HUD bar reads a correct fraction even when Max is
|
||||||
public float Max;
|
/// 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;
|
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>
|
/// <summary>
|
||||||
/// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for
|
/// 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
|
/// 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>
|
/// never floods past readable (also bounded by the director's MaxAlive count).</summary>
|
||||||
public const int BossSummonLiveCap = 8;
|
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) ----
|
// ---- 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>
|
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
|
||||||
public const uint BoonSourceIdSpan = 0x10000u;
|
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
|
/// <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
|
/// 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.
|
/// 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;
|
public const byte Returning = 4;
|
||||||
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
|
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
|
||||||
public const byte RouteSelect = 5;
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
|
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
|
||||||
public uint RouteGraceTick;
|
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 ----
|
// ---- boons ----
|
||||||
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
|
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
|
||||||
public uint BoonPickCounter;
|
public uint BoonPickCounter;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
map = RunMapMath.Generate(Seed);
|
map = RunMapMath.Generate(Seed);
|
||||||
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
||||||
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
|
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
|
||||||
em.SetComponentData(dir, new RunInfo
|
em.SetComponentData(dir, new RunInfo
|
||||||
{
|
{
|
||||||
Lifecycle = RunLifecycle.InRoom,
|
Lifecycle = RunLifecycle.InRoom,
|
||||||
@@ -73,7 +73,15 @@ namespace ProjectM.Tests
|
|||||||
static void MarkCleared(EntityManager em, Entity dir) =>
|
static void MarkCleared(EntityManager em, Entity dir) =>
|
||||||
em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 });
|
em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 });
|
||||||
|
|
||||||
static int RoomEntities(EntityManager em)
|
// DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick).
|
||||||
|
static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir)
|
||||||
|
{
|
||||||
|
em.SetComponentData(dir, new PortalCommand { HasInteract = 1 });
|
||||||
|
group.Update();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static int RoomEntities(EntityManager em)
|
||||||
{
|
{
|
||||||
var q = em.CreateEntityQuery(typeof(RoomTag));
|
var q = em.CreateEntityQuery(typeof(RoomTag));
|
||||||
int n = q.CalculateEntityCount();
|
int n = q.CalculateEntityCount();
|
||||||
@@ -82,48 +90,48 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump()
|
public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
|
||||||
{
|
{
|
||||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
// Room-0 content that must die at the RoomReward entry.
|
|
||||||
var node0 = em.CreateEntity(typeof(RoomTag));
|
var node0 = em.CreateEntity(typeof(RoomTag));
|
||||||
em.SetComponentData(node0, new RoomTag { Room = 0 });
|
em.SetComponentData(node0, new RoomTag { Room = 0 });
|
||||||
|
|
||||||
MarkCleared(em, dir);
|
MarkCleared(em, dir);
|
||||||
group.Update(); // InRoom -> RoomReward + teardown
|
group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here)
|
||||||
|
|
||||||
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
||||||
Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)");
|
Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
|
||||||
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
|
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
|
||||||
|
|
||||||
group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet)
|
group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
|
||||||
|
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
||||||
|
Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore");
|
||||||
|
|
||||||
|
PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate
|
||||||
var gateInfo = em.GetComponentData<RunInfo>(dir);
|
var gateInfo = em.GetComponentData<RunInfo>(dir);
|
||||||
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)");
|
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate");
|
||||||
|
Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)");
|
||||||
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
|
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
|
||||||
// Commit the party's pick directly through the server-only latch (the RPC path is pinned in
|
|
||||||
// RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1.
|
|
||||||
byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1);
|
byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1);
|
||||||
byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
|
byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
|
||||||
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
|
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
|
||||||
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
|
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
|
||||||
|
|
||||||
group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column
|
group.Update(); // RouteSelect -> InRoom room 1 at the PICKED column
|
||||||
|
|
||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
var run = em.GetComponentData<RunRuntime>(dir);
|
var run = em.GetComponentData<RunRuntime>(dir);
|
||||||
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
|
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
|
||||||
Assert.AreEqual(1, info.CurrentRoom);
|
Assert.AreEqual(1, info.CurrentRoom);
|
||||||
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)");
|
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column");
|
||||||
Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped");
|
Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped");
|
||||||
Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
|
Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
|
||||||
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
|
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
|
||||||
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
|
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
|
||||||
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
|
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
|
||||||
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
|
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
|
||||||
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f,
|
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f, "party teleported (+1500)");
|
||||||
"party teleported onto the idle sub-slot (+1500)");
|
|
||||||
world.Dispose();
|
world.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +142,6 @@ namespace ProjectM.Tests
|
|||||||
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
|
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
int bossLayer = map0.LayerCount - 1;
|
int bossLayer = map0.LayerCount - 1;
|
||||||
// Jump the state to the boss room.
|
|
||||||
var info0 = em.GetComponentData<RunInfo>(dir);
|
var info0 = em.GetComponentData<RunInfo>(dir);
|
||||||
info0.CurrentRoom = bossLayer;
|
info0.CurrentRoom = bossLayer;
|
||||||
em.SetComponentData(dir, info0);
|
em.SetComponentData(dir, info0);
|
||||||
@@ -145,8 +152,9 @@ namespace ProjectM.Tests
|
|||||||
em.SetComponentData(dir, run0);
|
em.SetComponentData(dir, run0);
|
||||||
|
|
||||||
MarkCleared(em, dir);
|
MarkCleared(em, dir);
|
||||||
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1)
|
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists)
|
||||||
group.Update(); // RoomReward -> Returning
|
group.Update(); // RoomReward -> RoomExplore
|
||||||
|
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
|
||||||
group.Update(); // Returning: bank + teleport home -> Staging
|
group.Update(); // Returning: bank + teleport home -> Staging
|
||||||
|
|
||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
@@ -162,7 +170,7 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
|
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
|
||||||
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
|
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
|
||||||
|
|
||||||
group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch)
|
group.Update();
|
||||||
group.Update();
|
group.Update();
|
||||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
|
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
|
||||||
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
|
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
|
||||||
@@ -197,17 +205,17 @@ namespace ProjectM.Tests
|
|||||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
MarkCleared(em, dir);
|
MarkCleared(em, dir);
|
||||||
group.Update(); // -> RoomReward (teardown)
|
group.Update();
|
||||||
group.Update(); // -> RouteSelect (gate open, grace armed at T0)
|
group.Update(); // -> RoomExplore
|
||||||
|
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
|
||||||
|
|
||||||
var gate = em.GetComponentData<RunInfo>(dir);
|
var gate = em.GetComponentData<RunInfo>(dir);
|
||||||
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
|
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
|
||||||
byte pickIdx = (byte)(gate.RouteOptionCount - 1);
|
byte pickIdx = (byte)(gate.RouteOptionCount - 1);
|
||||||
byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col;
|
byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col;
|
||||||
|
|
||||||
// A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence).
|
|
||||||
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
|
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
|
||||||
SetTick(world, T0 + 100000); // way past any grace
|
SetTick(world, T0 + 100000);
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
@@ -222,12 +230,13 @@ namespace ProjectM.Tests
|
|||||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
MarkCleared(em, dir);
|
MarkCleared(em, dir);
|
||||||
group.Update(); // -> RoomReward
|
group.Update();
|
||||||
group.Update(); // -> RouteSelect
|
group.Update(); // -> RoomExplore
|
||||||
|
PortalAdvance(em, group, dir); // -> RouteSelect
|
||||||
|
|
||||||
var gate = em.GetComponentData<RunInfo>(dir);
|
var gate = em.GetComponentData<RunInfo>(dir);
|
||||||
byte lowestCol = gate.RouteOpt0Col;
|
byte lowestCol = gate.RouteOpt0Col;
|
||||||
SetTick(world, T0 + 100000); // grace elapses, nobody picked
|
SetTick(world, T0 + 100000);
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
@@ -242,8 +251,9 @@ namespace ProjectM.Tests
|
|||||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
MarkCleared(em, dir);
|
MarkCleared(em, dir);
|
||||||
group.Update(); // -> RoomReward
|
group.Update();
|
||||||
group.Update(); // -> RouteSelect
|
group.Update(); // -> RoomExplore
|
||||||
|
PortalAdvance(em, group, dir); // -> RouteSelect
|
||||||
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
|
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
|
||||||
|
|
||||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
|
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
|
||||||
@@ -251,7 +261,7 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle);
|
Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle);
|
||||||
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)");
|
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3)");
|
||||||
world.Dispose();
|
world.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user